Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
17e36c3
feat(noema): route in-process decisions through contextual-orchestrator
cursoragent Aug 16, 2026
f59ab2d
test(noema): lock orchestrator-only LLM routing
cursoragent Aug 16, 2026
15ec8c8
fix(noema): keep first-slice orchestrator routing only
cursoragent Aug 16, 2026
20a95af
test(noema): prove gateway client failures fail closed
seonghobae Aug 16, 2026
38f4e97
fix(noema): fail closed when orchestrator URL is rejected
cursoragent Aug 17, 2026
a8eec44
merge(develop): refresh Noema orchestrator slice
cursoragent Aug 17, 2026
67e7231
Merge branch 'develop' into cursor/noema-orchestrator-decision-agent-…
seonghobae Aug 17, 2026
5f374ff
test(noema): cover rejected gateway client paths
seonghobae Aug 19, 2026
1fc2733
feat(noema): add signed gateway settings (#1425)
seonghobae Aug 20, 2026
7f18343
fix(noema): classify encryption configuration errors
seonghobae Aug 20, 2026
2660132
test(noema): cover wrapped encryption failures
seonghobae Aug 20, 2026
c8890a7
test(secrets): assert typed encryption configuration error
seonghobae Aug 20, 2026
7c29c9e
chore: sync Noema gateway branch with develop
seonghobae Aug 20, 2026
93dcfb9
fix(noema): handle encryption errors during config reads
seonghobae Aug 20, 2026
414cb60
style(noema): remove trailing blank line
seonghobae Aug 20, 2026
c0fbc32
fix(noema): keep gateway DNS validation off event loop
seonghobae Aug 20, 2026
4b4e7ac
fix: remove unreachable noema response
seonghobae Aug 20, 2026
0fd3301
Merge remote-tracking branch 'origin/develop' into codex/pr1384-recon…
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ in this repo.
add further `os.getenv` secret reads, and migrate toward the KV pattern as it
is adopted.

### Noema LLM routing (orchestrator-only)

- **Noema's LLM path is contextual-orchestrator only.**
`services.noema_agent:run_noema_agent` must not call
`resolve_runtime_llm_provider`, must not pick a tenant `OpenAIChatModel` /
`gpt-4o`, and must not copy draft email-writing clients that require a
tenant `model_profile_id`. Send the single model alias
`contextual-orchestrator` to a dedicated gateway inference token + HTTPS
`/v1` base URL from the Fernet tenant KV (`noema_orchestrator_token`,
`noema_orchestrator_base_url`). Catalog files
(`registered_agents.json` `provider_source=contextual-orchestrator`,
`task_agent_mapping.json`) stay catalog-only; do not wire Decision Points
or `mail.triage` dispatchers in the same slice.
- **Do not sequentially fail over** to the next agent or model inside naruon
or Noema. Do not copy OpenCode sidecar model lists. Never use
`COPILOT_GITHUB_TOKEN` or GitHub Models for Noema.
- **Upstream provider keys stay in the orchestrator KV**, not in naruon at
request time: `NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`,
`BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`. List prices for
free-but-priced models are stored in the orchestrator, not here. Do not
reimplement the orchestrator catalog in this repo. Keep the existing
owner-scoped tools and opt-in writeback surface.

### This repo's role in the ecosystem

- **This repo (naruon) is the ECOSYSTEM HUB:** email/PIM that DOM-decomposes
Expand Down
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ syntax, no userinfo/query or fragment, exact host membership in
Missing allowlist configuration fails closed; the default provider path should
leave `base_url` unset.

## Noema LLM routing

Noema remains the in-process general agent (`registered_agents.json` →
`services.noema_agent:run_noema_agent`) with the existing owner-scoped
tools and opt-in writeback surface. Its LLM calls go only to
**contextual-orchestrator**: dedicated Fernet-KV inference token, HTTPS
base URL ending in `/v1`, and the single model alias
`contextual-orchestrator`. Catalog mappings (`mail.triage`, and the rest)
are catalog-only; they are not a live Decision Points dispatcher. naruon
does not hold upstream provider keys at request time and does not
sequentially fail over models. Design:
[`docs/architecture/noema-decision-agent.md`](docs/architecture/noema-decision-agent.md).

## Batch embedding routing boundary

Bulk, latency-tolerant embedding work (email import, backfills) does not call a
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
## [Unreleased]
- **Noema LLM routing through contextual-orchestrator.**
`run_noema_agent` no longer calls `resolve_runtime_llm_provider` or a
tenant `gpt-4o` chat model. Completions go to the orchestrator gateway
(dedicated Fernet-KV inference token `noema_orchestrator_token`, HTTPS
`/v1` URL `noema_orchestrator_base_url`, model alias
`contextual-orchestrator`). Catalog `provider_source` is
`contextual-orchestrator`. Existing tools, owner-scope, and opt-in
writeback stay. This slice does not add a Decision Points / `mail.triage`
dispatcher. naruon does not sequentially fail over models and does not
read upstream provider keys (`NVIDIA_NIM_API_KEY`, `BYTEZ_API_KEY`,
`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `COPILOT_GITHUB_TOKEN`) at
request time. See `docs/architecture/noema-decision-agent.md`.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.
### 주제 측정 경계 (Topic Measurement)

Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ indexes, and auditable writeback intent. `ARCHITECTURE.md` and
```
Next.js frontend ──> FastAPI backend (control plane) ──> Postgres + pgvector
├──> Noema agent LLM ──> contextual-orchestrator (/v1)
├──> OpenAI-compatible LLM providers (Ollama locally)
└──> outbound-only self-hosted connector (connector/)
└──> customer IMAP/POP3/SMTP + CalDAV/CardDAV/WebDAV
Expand All @@ -121,6 +122,12 @@ Next.js frontend ──> FastAPI backend (control plane) ──> Postgres + pgve
(`services/threading_service.py` is the only thread-id assignment owner),
vector search, AI summaries, ticket tasks, and server-authoritative
calendar/WebDAV writeback intents. Authorization is deny-first RBAC + ABAC.
In-process **Noema** (`services/noema_agent.py`) keeps the existing
owner-scoped tool surface; its LLM calls go only to
contextual-orchestrator (model alias `contextual-orchestrator`, dedicated
gateway token + HTTPS `/v1` URL from the Fernet KV). naruon does not hold
upstream provider keys, pick tenant `gpt-4o`, or sequentially fail over
models. Catalog mappings are not a live dispatcher.
- `frontend/` — Next.js workspace shell (Today dashboard, Mail, Calendar,
Tasks, Projects, Context Search, AI Hub, Data, Security, Settings). Browser
writes go through the same-origin `/api/*` proxy, which converts the HttpOnly
Expand Down
54 changes: 54 additions & 0 deletions backend/alembic/versions/0018_noema_orch_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""add noema contextual-orchestrator gateway columns

Revision ID: 0018_noema_orch_gateway
Revises: 0017_merge_newsdom_carddav_heads
Create Date: 2026-08-16 00:00:00.000000

Noema judgments call only the contextual-orchestrator OpenAI-compatible
gateway. The dedicated inference token and HTTPS ``/v1`` base URL live on
``tenant_configs`` in the Fernet KV (token is EncryptedString). naruon does
not store upstream provider keys for this path.
"""

from alembic import op
import sqlalchemy as sa

revision = "0018_noema_orch_gateway"
down_revision = "0017_merge_newsdom_carddav_heads"
branch_labels = None
depends_on = None

_TENANT_TABLE = "tenant_configs"


def upgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if not inspector.has_table(_TENANT_TABLE):
return
for column in _noema_gateway_columns():
if not _has_column(inspector, _TENANT_TABLE, column.name):
op.add_column(_TENANT_TABLE, column)


def downgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if not inspector.has_table(_TENANT_TABLE):
return
for column in reversed(_noema_gateway_columns()):
if _has_column(inspector, _TENANT_TABLE, column.name):
op.drop_column(_TENANT_TABLE, column.name)


def _noema_gateway_columns() -> list["sa.Column"]:
return [
sa.Column("noema_orchestrator_base_url", sa.String(), nullable=True),
sa.Column("noema_orchestrator_token", sa.String(), nullable=True),
]


def _has_column(inspector, table_name: str, column_name: str) -> bool:
return any(
column["name"] == column_name for column in inspector.get_columns(table_name)
)
10 changes: 10 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,16 @@ class TenantConfig(Base):
batch_attribution_group: Mapped[str | None] = mapped_column(String, nullable=True)
batch_attribution_company: Mapped[str | None] = mapped_column(String, nullable=True)

# Dedicated contextual-orchestrator inference gateway for in-process Noema
# judgments. Token is EncryptedString (Fernet KV); URL is SSRF-guarded at
# call time. Upstream provider keys stay in the orchestrator KV.
noema_orchestrator_base_url: Mapped[str | None] = mapped_column(
String, nullable=True
)
noema_orchestrator_token: Mapped[str | None] = mapped_column(
EncryptedString, nullable=True
)

def __repr__(self) -> str:
return (
f"<TenantConfig(id={self.id}, user_id='{self.user_id}', "
Expand Down
7 changes: 4 additions & 3 deletions backend/requirements-agent.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Optional dependencies for the Noema general agent (services/noema_agent.py).
#
# Pydantic-AI is MIT licensed and builds on the OpenAI-compatible provider layer
# already used by naruon (openai, httpx, pydantic). Installing this enables the
# agent; without it, services/noema_agent.py degrades to a structured no-op.
# Pydantic-AI is MIT licensed and builds on the OpenAI-compatible
# contextual-orchestrator gateway (openai, httpx, pydantic). Installing this
# enables the agent; without it, services/noema_agent.py degrades to a
# structured no-op. Model selection stays in the orchestrator.
#
# Kept in a small hash-pinned optional set so the core runtime lock remains
# focused while CI can still run the real agent build-path test deterministically.
Expand Down
8 changes: 8 additions & 0 deletions backend/scripts/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ def _get_add_columns_statements() -> list[Executable]:
text(
"ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_password varchar"
),
text(
"ALTER TABLE tenant_configs "
"ADD COLUMN IF NOT EXISTS noema_orchestrator_base_url varchar"
),
text(
"ALTER TABLE tenant_configs "
"ADD COLUMN IF NOT EXISTS noema_orchestrator_token varchar"
),
text(
"ALTER TABLE sender_relationships "
"ADD COLUMN IF NOT EXISTS source_message_id varchar"
Expand Down
4 changes: 4 additions & 0 deletions backend/services/agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class RegisteredAgent:
description: str = ""
capabilities: tuple[str, ...] = ()
provider_source: str = ""
model_alias: str = ""
sequential_failover: bool = False
writeback_opt_in: bool = False
writeback_audit_logged: bool = False
degrades_gracefully: bool = False
Expand Down Expand Up @@ -75,6 +77,8 @@ def _agent_from_entry(agent_id: str, entry: dict[str, Any]) -> RegisteredAgent |
description=str(entry.get("description", "") or ""),
capabilities=_coerce_capabilities(entry.get("capabilities")),
provider_source=str(entry.get("provider_source", "") or ""),
model_alias=str(entry.get("model_alias", "") or ""),
sequential_failover=bool(entry.get("sequential_failover", False)),
writeback_opt_in=bool(writeback.get("opt_in", False)),
writeback_audit_logged=bool(writeback.get("audit_logged", False)),
degrades_gracefully=bool(entry.get("degrades_gracefully", False)),
Expand Down
6 changes: 6 additions & 0 deletions backend/services/llm_provider_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ async def resolve_runtime_llm_provider(
user_id: str,
organization_id: str | None,
) -> RuntimeLLMProvider | None:
"""Resolve the tenant Fernet LLM provider for search / chat / embeddings.

Noema is not a caller. Decision-agent completions go through
``services.orchestrator_gateway.resolve_orchestrator_gateway`` so naruon
never places NVIDIA / OpenAI / OpenRouter / Bytez keys on that path.
"""
active_provider = await get_active_llm_provider(session, organization_id)
if active_provider is not None:
runtime_provider = _runtime_from_provider(active_provider)
Expand Down
Loading
Loading