Skip to content

Commit 1849d20

Browse files
Give Architect its project context (#2557)
* feat(backend): tell architect which project it is in The Architect asked "which project?" on every session because it was never told. session.project_id reaches the DB tenant GUC, the X-Project-Id header on every tool call, and the Celery headers — but never the prompt. It cannot resolve this itself either: the project table is not covered by the project_isolation RLS policy, so list_projects returns every project in the organization with nothing marking the active one. Resolve the project in build_agent, pass it to ArchitectAgent, and render a block in the iteration prompt. A project that no longer resolves degrades to no block rather than failing the turn. The block is explicit that reads cover this project plus organization-level entities, and that other projects' contents are not readable — RLS matches project_id = current OR project_id IS NULL, so claiming otherwise would have the agent give wrong reasons for what it sees. * fix(backend): stop telling architect to resolve a project create_endpoint's tool doc said project_id was "REQUIRED — the endpoint cannot be created without it", and its parameter doc said to resolve it via list_projects and "ask if ambiguous". Both are wrong: EndpointCreate.project_id is Optional and auto_stamp fills it from the request scope. The agent followed the docs, so it asked. Correct create_endpoint and get_project in mcp_tools.yaml and mirror the fix into the public tool-catalog.md. Rework the phase guidance that pushed the same way: discovery no longer resolves project_id before create_endpoint, planning no longer plans a project, creation only creates one when the user asked for a new project, and save_plan's project field is documented as new-project-only. * test(backend): cover architect project context Pin the two facts that make the lookup necessary — the project table has no project_id column, so list_projects cannot self-filter — and the degradation paths, so a deleted project or a malformed id omits the block instead of failing the turn. Also assert no tool doc tells the agent to ask for a project, and that the prompt block stays honest about organization-level rows and unreadable projects. * fix(sdk): flatten and clip project fields before the prompt Project names and descriptions are user-controlled and the context block is line-structured, so a newline in either forged a field: a description of "harmless\nProject: Evil" rendered as a second Project: line and the agent read a project the user never set. Collapse whitespace and clip to config caps. The description is rendered every turn, so an unbounded one was paid for on each. Also name the exception in the resolver's warning, matching the sibling degrade-gracefully logs in attachments.py and chat.py.
1 parent d46efb7 commit 1849d20

14 files changed

Lines changed: 414 additions & 18 deletions

File tree

‎apps/backend/src/rhesis/backend/app/mcp_server/mcp_tools.yaml‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ tools:
1515
path: /projects/{project_id}
1616
description: >
1717
Get one project by ID. ENTITY: Project. PREREQUISITES: project_id
18-
from list_projects. CHAIN: use before create_endpoint (project_id
19-
is required) or when confirming project context for an endpoint.
20-
NEVER: ask the user for a project ID when they gave a name — use
21-
list_projects with $filter first.
18+
from list_projects. CHAIN: use when the user asks about a specific
19+
project by name. NOT needed before create_endpoint — that takes its
20+
project from the request scope. NEVER: ask the user for a project ID
21+
when they gave a name — use list_projects with $filter first.
2222
parameters:
2323
project_id:
2424
description: "REQUIRED. UUID of the project."
@@ -999,18 +999,18 @@ tools:
999999
requires_confirmation: true
10001000
description: >
10011001
Register a new REST API endpoint (an AI system to be tested — a chat
1002-
API, completion API, or chatbot). Resolve the target project FIRST with
1003-
list_projects and pass its id as project_id (REQUIRED — the endpoint
1004-
cannot be created without it). Do NOT send server-managed fields
1002+
API, completion API, or chatbot). The endpoint is created in the
1003+
current project automatically — do NOT resolve a project or ask the
1004+
user which one to use. Do NOT send server-managed fields
10051005
(id, user_id, organization_id, status_id, created_at, updated_at).
10061006
After creating, you can verify reachability with check_endpoint.
10071007
parameters:
10081008
name:
10091009
description: "REQUIRED. Endpoint name, unique within the project (string)."
10101010
project_id:
10111011
description: >
1012-
REQUIRED. UUID of the project this endpoint belongs to. Resolve it
1013-
via list_projects (use the user's project, or ask if ambiguous).
1012+
Omit. The project scope is taken from the request, not from this
1013+
field.
10141014
connection_type:
10151015
description: 'Connection protocol. Use "REST" for HTTP APIs (default for chatbots).'
10161016
url:

‎apps/backend/src/rhesis/backend/app/services/architect/runner.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ async def prepare_and_load_session(
117117
return resolved_session_id, session_data
118118

119119

120+
def _resolve_project_context(db, project_id: Optional[str], organization_id: str) -> Optional[dict]:
121+
"""Look up the session's project so the agent can name it.
122+
123+
The agent cannot resolve this itself: ``project`` is not covered by the
124+
``project_isolation`` RLS policy, so ``list_projects`` returns every
125+
project in the organization with nothing marking the active one.
126+
127+
A project that no longer resolves is not fatal — the prompt block is
128+
simply omitted and the agent behaves as it did before.
129+
"""
130+
if not project_id:
131+
return None
132+
133+
from uuid import UUID
134+
135+
from rhesis.backend.app.crud import project as project_crud
136+
137+
try:
138+
project = project_crud.get_project(db, UUID(project_id), organization_id=organization_id)
139+
except Exception as exc:
140+
logger.warning("Could not resolve project %s for Architect context: %s", project_id, exc)
141+
return None
142+
143+
if not project:
144+
return None
145+
146+
return {
147+
"project_id": str(project.id),
148+
"name": project.name or "",
149+
"description": project.description or "",
150+
}
151+
152+
120153
@observe()
121154
async def build_agent(
122155
session_data: Dict[str, Any],
@@ -144,6 +177,7 @@ async def build_agent(
144177
raise ValueError(f"User {user_id} is inactive")
145178
delegation_token = create_service_delegation_token(user, "backend")
146179
model = get_user_generation_model(db, user)
180+
project_context = _resolve_project_context(db, project_id, organization_id)
147181

148182
agent_state = session_data["agent_state"]
149183
snapshot = ArchitectAgentStateSnapshot(
@@ -174,6 +208,7 @@ async def build_agent(
174208
event_handlers=[ws_handler, *tracing_handlers],
175209
max_iterations=snapshot.max_iterations,
176210
verbose=False,
211+
project_context=project_context,
177212
)
178213
agent.restore_state(snapshot)
179214

‎sdk/src/rhesis/sdk/agents/architect/agent.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def __init__(
139139
history_window: Optional[int] = None,
140140
verbose: bool = False,
141141
event_handlers: Optional[List[AgentEventHandler]] = None,
142+
project_context: Optional[Dict[str, Any]] = None,
142143
):
143144
self._cfg = config or ArchitectConfig()
144145
templates_dir = Path(__file__).parent / "prompt_templates"
@@ -155,6 +156,10 @@ def __init__(
155156
jinja_env=build_architect_jinja_env(templates_dir),
156157
)
157158
self._conversation_history: List[Dict[str, Any]] = []
159+
# The project this session is scoped to. Tool calls carry it as
160+
# X-Project-Id, so the agent must not ask the user which project
161+
# they mean — see _format_project_context.
162+
self._project_context: Dict[str, Any] = project_context or {}
158163
self._plan: Optional[ArchitectPlan] = None
159164
self._mode: AgentMode = AgentMode.DISCOVERY
160165
self._workflow_path: WorkflowPath = WorkflowPath.UNSET
@@ -1516,8 +1521,52 @@ def _build_prompt(
15161521
plan_progress_text=plan_progress_text,
15171522
discovery_state_text=discovery_text,
15181523
attachments_text=attachments_text,
1524+
project_context_text=self._format_project_context(),
15191525
)
15201526

1527+
def _format_project_context(self) -> str:
1528+
"""Describe the project this session is scoped to.
1529+
1530+
Without this the agent has no way to know: the ``project`` table is
1531+
not covered by the ``project_isolation`` RLS policy, so
1532+
``list_projects`` returns every project in the organization with
1533+
nothing marking the active one. It would ask the user instead.
1534+
"""
1535+
name = self._clean_project_field(
1536+
self._project_context.get("name"), self._cfg.project_name_max_chars
1537+
)
1538+
project_id = (self._project_context.get("project_id") or "").strip()
1539+
if not name and not project_id:
1540+
return ""
1541+
1542+
label = name or "(unnamed)"
1543+
lines = [f"Project: {label}"]
1544+
if project_id:
1545+
lines.append(f"Project ID: {project_id}")
1546+
description = self._clean_project_field(
1547+
self._project_context.get("description"),
1548+
self._cfg.project_description_max_chars,
1549+
)
1550+
if description:
1551+
lines.append(f"About: {description}")
1552+
return "\n".join(lines)
1553+
1554+
@staticmethod
1555+
def _clean_project_field(value: Any, max_chars: int) -> str:
1556+
"""Flatten and clip a project field before it enters the prompt.
1557+
1558+
The block is line-structured, so a newline inside a name or
1559+
description would forge a field the user never set (``About: x\nProject:
1560+
Other``). Collapsing whitespace removes that, and the clip keeps a long
1561+
description from being paid for on every turn.
1562+
"""
1563+
if not isinstance(value, str):
1564+
return ""
1565+
flattened = " ".join(value.split())
1566+
if len(flattened) > max_chars:
1567+
return flattened[:max_chars].rstrip() + "…"
1568+
return flattened
1569+
15211570
def _format_discovery_state(self) -> str:
15221571
"""Format the discovery state for the iteration prompt."""
15231572
ds = self._discovery_state

‎sdk/src/rhesis/sdk/agents/architect/config.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ class ArchitectConfig:
4040
recent_msg_max_chars: int = 2_000
4141
older_msg_max_chars: int = 500
4242

43+
# ── project context ───────────────────────────────────────────
44+
# Rendered every turn, so an unbounded description would be paid for
45+
# on each one.
46+
project_name_max_chars: int = 200
47+
project_description_max_chars: int = 500
48+
4349
# ── streaming / tool-result preview ───────────────────────────
4450
tool_result_preview_chars: int = 4_000
4551
reasoning_preview_chars: int = 200

‎sdk/src/rhesis/sdk/agents/architect/prompt_templates/iteration_prompt.j2‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@ Current Mode: {{ mode }}
33

44
User Message: {{ user_query }}
55

6+
{% if project_context_text %}
7+
Current Project (already resolved — do not ask):
8+
{{ project_context_text }}
9+
10+
Every tool call you make is already scoped to this project. Do not ask the user
11+
which project they mean, and do not call `list_projects` to pick one. Entities
12+
you create land here automatically — omit `project_id` and let the request scope
13+
fill it in.
14+
15+
Reads return this project's entities plus organization-level ones (those with no
16+
project). Other projects' contents are not visible: you can name them via
17+
`list_projects` and talk about them, but you cannot read or write their
18+
requirements, metrics, test sets or runs from this session. If the user asks for
19+
another project's data, say that plainly rather than reporting an empty result as
20+
though the project were empty.
21+
{% endif %}
22+
623
{% if phase_knowledge_text %}
724
Phase Guidance:
825
{{ phase_knowledge_text }}

‎sdk/src/rhesis/sdk/agents/architect/prompt_templates/telemachus-save-plan.j2‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ When you have formulated the plan, you MUST call **save_plan** before presenting
44
**save_plan is strictly validated** — the call fails if any field is missing, has the wrong type, or uses a value outside the allowed set. Send arguments exactly as specified below.
55

66
Top-level structure (all three array fields are required, even if empty):
7-
- `project`: `{name, description}` — optional. Omit entirely when no project is needed (ad-hoc tests, single test set for an existing endpoint). Do NOT pass `null` or an empty object.
7+
- `project`: `{name, description}` — only for creating a **new** project, which is rare. The session is already scoped to a project and entities land there automatically, so never use this field to name the project you are already working in. Omit it entirely in the normal case. Do NOT pass `null` or an empty object.
88
- `requirements`: array (required, may be empty)
99
- `test_sets`: array (required, may be empty)
1010
- `metrics`: array (required, may be empty)
@@ -53,7 +53,7 @@ Common validation pitfalls that will be rejected:
5353
- `test_type: "single-turn"` or `"singleturn"` → must be `"Single-Turn"` or `"Multi-Turn"` (exact case, hyphen)
5454
- `num_tests: "15"` → must be the integer `15`
5555
- Top-level keys missing because the section was empty → send `[]`, not omit
56-
- `project: null` or `project: {}` when there is no project → omit the key entirely
56+
- `project: null`, `project: {}`, or `project` naming the current project → omit the key entirely
5757
- Extra fields not listed above are ignored, but typos in field names mean the data is dropped
5858

5959
When save_plan fails the error response lists each invalid field with its reason. Read it, fix the offending fields, and re-call save_plan with the corrected arguments. Do NOT proceed to other tools while the plan is unsaved.

‎skills/rhesis/references/phases/creation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Execute the approved plan exactly — no extra entities.
55
## Order (mandatory)
66

77
1. Reuse lookup — resolve IDs via `list_*` + `$filter` if needed
8-
2. `create_project` — only if planned
8+
2. `create_project` — only when the user asked for a **new** project. The session already has one; never create a project to "hold" this work
99
3. `create_requirement` — **(new)** only; name + description
1010
4. Resolve all requirement IDs
1111
5. Metrics — **(reuse)** skip; **(improve)** `improve_metric`; **(new)** `create_metric` with plan name, **`metric_scope`**, `score_type`, `evaluation_prompt`, plus `description`, `evaluation_steps`, `reasoning`, `explanation` written to the depth in `metric-authoring.md`. Do NOT use `generate_metric`

‎skills/rhesis/references/phases/discovery.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
When the user names an endpoint or wants to test an AI application:
44

5-
1. Resolve endpoint: `list_endpoints` with `$select=name,id,url,description`. If missing, `create_endpoint` (resolve `project_id` via `list_projects` first).
5+
1. Resolve endpoint: `list_endpoints` with `$select=name,id,url,description`. If missing, `create_endpoint` — it lands in the current project automatically, so omit `project_id`.
66
2. `check_endpoint` — report failures before proceeding.
77

88
If the project has **no** endpoint at all, lead your reply with that before any test design: nothing can run against the project until one is connected, so there are no responses to score. Show both routes — register the application's HTTP API, or wrap a Python function with the SDK's `@endpoint` decorator ([connecting an application](https://docs.rhesis.ai/docs/getting-started/connecting-application)) — and offer to create it now. Say it once; if the user would rather design tests first, continue.

‎skills/rhesis/references/phases/planning.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ State the type for each test set when you present the plan, and check that the m
4848

4949
Propose existing entities when they match. Say explicitly: "I'll reuse 'Refuses Harmful Requests'."
5050

51-
Skip project for ad-hoc work.
51+
Never plan a project. The session is already scoped to one, and entities land there automatically — plan a project only when the user explicitly asks for a new one.
5252

5353
## Approval gate
5454

‎skills/rhesis/references/tool-catalog.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ List configured endpoints (the AI systems under test).
2121
### `create_endpoint`
2222
Register a new REST API endpoint (the AI system to be tested — a chat API, completion API, or chatbot).
2323

24-
Resolve the target project **first** with `list_projects` and pass its id — the endpoint cannot be created without a `project_id`. After creating, verify reachability with `check_endpoint`.
24+
The endpoint is created in the current project automatically — do not resolve a project or ask which one to use. After creating, verify reachability with `check_endpoint`.
2525

2626
**Key parameters:**
2727
- `name` (required) — endpoint name, unique within the project
28-
- `project_id` (required) — UUID of the owning project; resolve via `list_projects`
28+
- `project_id` — omit; the project scope is taken from the request
2929
- `connection_type` — `"REST"` for HTTP APIs (default for chatbots)
3030
- `url` (required for REST) — full endpoint URL, e.g. `https://api.example.com/chat`
3131
- `method` — HTTP method, e.g. `"POST"` or `"GET"`
@@ -42,7 +42,7 @@ Resolve the target project **first** with `list_projects` and pass its id — th
4242
- `auth_token` — bearer token / API key (write-only, stored encrypted)
4343
- `client_id`, `client_secret`, `token_url`, `scopes`, `audience` — OAuth client-credentials fields (`client_secret` is write-only, stored encrypted)
4444

45-
**Common mistakes:** Omitting `project_id` — the create fails because it is a required relationship. Always resolve the project with `list_projects` first. Do NOT send server-managed fields (`id`, `user_id`, `organization_id`, `status_id`, `created_at`, `updated_at`) — `status_id` is auto-assigned to "Active".
45+
**Common mistakes:** Sending `project_id` — it is filled from the request scope, and passing one is how endpoints end up in the wrong project. Do NOT send server-managed fields (`id`, `user_id`, `organization_id`, `status_id`, `created_at`, `updated_at`) — `status_id` is auto-assigned to "Active".
4646

4747
---
4848

@@ -534,7 +534,7 @@ Create a knowledge source for grounding Single-Turn `generate_test_set`.
534534
### `get_project`
535535
Get one project by ID.
536536

537-
**CHAIN:** after `list_projects` when you need full project detail before `create_endpoint`.
537+
**CHAIN:** after `list_projects` when the user asks about a specific project by name. Not needed before `create_endpoint` — that takes its project from the request scope.
538538

539539
**Key parameters:** `project_id` (required)
540540

0 commit comments

Comments
 (0)