Skip to content

Commit d5a8aa9

Browse files
committed
feat(agents): stamp the project scope into runContext; pool keys prefer it over the mount
Decision 1 option (b): the runner's keep-alive pool scopes sessions by project id. Until now that scope came only from the mount-sign response, so a run without a durable mount could never park. The trustworthy project id now rides runContext: the service stamps it server-side from the request's authenticated baggage (never from a caller-supplied wire field), and the runner PREFERS runContext.project.id, falling back to the mount scope. The no-scope-no-park safety rule is unchanged. Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
1 parent 6570670 commit d5a8aa9

15 files changed

Lines changed: 250 additions & 36 deletions

File tree

docs/design/agent-workflows/documentation/running-the-agent.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,12 @@ sections).
178178
(a `0` would re-disable auto-stop and reintroduce the leak).
179179
- `AGENTA_RUNNER_SESSION_KEEPALIVE`. Gates session keep-alive: after a turn ends, the runner
180180
parks the live harness session and continues it on the next matching message in the same
181-
conversation, instead of cold-replaying the transcript. Default off. Local sandbox only;
182-
requires mount signing (no mount scope means the session never parks). Design:
181+
conversation, instead of cold-replaying the transcript. Default off. Local sandbox only. The
182+
parked-session pool is keyed by project scope so a live session never crosses a project
183+
boundary. That scope comes from the project id the service stamps into the run context
184+
(`runContext.project.id`, derived server-side from the request's auth, never from a
185+
caller-supplied field); the mount's owning project id is the fallback when a run carries no
186+
stamped project. A run with no project scope from either source never parks. Design:
183187
`docs/design/agent-workflows/projects/session-keepalive/plan.md`.
184188
- `AGENTA_RUNNER_SESSION_TTL_MS`. How long an idle parked session lives before it is
185189
destroyed. Default `60000`.

sdks/python/agenta/sdk/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
PermissionMode,
7171
PiAgentTemplate,
7272
RunContext,
73+
RunContextProject,
7374
RunContextReference,
7475
RunContextRun,
7576
RunContextTrace,
@@ -175,6 +176,7 @@
175176
"ui_message_stream",
176177
"TraceContext",
177178
"RunContext",
179+
"RunContextProject",
178180
"RunContextReference",
179181
"RunContextRun",
180182
"RunContextWorkflow",

sdks/python/agenta/sdk/agents/dtos.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,19 @@ class RunContextRun(BaseModel):
466466
kind: Optional[str] = None
467467

468468

469+
class RunContextProject(BaseModel):
470+
"""The run's owning project identity inside ``runContext`` (so ``$ctx.project.id`` is
471+
addressable, mirroring how the workflow references nest).
472+
473+
``id`` is the SERVER-derived project id: the service stamps it from its own request state (the
474+
authenticated OTel baggage), NEVER from anything the caller sends. It is the project scope the
475+
runner trusts for session keep-alive — the runner keys its parked-session pool on it so a live
476+
session can never be resumed across a project boundary. See :class:`RuntimeAuthContext` for the
477+
same "from the request state, never the request body" discipline."""
478+
479+
id: Optional[str] = None
480+
481+
469482
class RunContext(BaseModel):
470483
"""The run's own context, delivered on ``/run`` and refreshed per turn (direct-call tools,
471484
Phase 3a; see ``projects/direct-call-tools/run-context.md``).
@@ -481,9 +494,14 @@ class RunContext(BaseModel):
481494
id is NOT carried here — it rides the top-level ``sessionId`` field, and the runner owns the
482495
live id across turns; duplicating it in run context would only let it go stale. ``to_wire``
483496
emits only the sub-objects/fields that are set, so a run with no identity yields an empty blob
484-
(and the serializer omits the key entirely)."""
497+
(and the serializer omits the key entirely).
498+
499+
``project`` carries the run's owning project id, stamped server-side (see
500+
:class:`RunContextProject`). It is the trustworthy project scope for session keep-alive: the
501+
runner prefers it over the mount-derived scope when keying its parked-session pool."""
485502

486503
run: Optional[RunContextRun] = None
504+
project: Optional[RunContextProject] = None
487505
workflow: Optional[RunContextWorkflow] = None
488506
trace: Optional[RunContextTrace] = None
489507

@@ -497,6 +515,14 @@ def to_wire(self) -> Dict[str, Any]:
497515
}
498516
if run:
499517
out["run"] = run
518+
if self.project is not None:
519+
project = {
520+
key: value
521+
for key, value in self.project.model_dump().items()
522+
if value is not None
523+
}
524+
if project:
525+
out["project"] = project
500526
if self.workflow is not None:
501527
workflow: Dict[str, Any] = {}
502528
for entity in ("artifact", "variant", "revision"):

sdks/python/agenta/sdk/agents/tracing.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from agenta.sdk.agents.dtos import (
2323
RunContext,
24+
RunContextProject,
2425
RunContextReference,
2526
RunContextTrace,
2627
RunContextWorkflow,
@@ -180,6 +181,23 @@ def _run_context_trace() -> Optional[RunContextTrace]:
180181
)
181182

182183

184+
def _run_context_project() -> Optional[RunContextProject]:
185+
"""The run's owning project id, read from the SERVER-derived request context.
186+
187+
The source is the authenticated OTel ``baggage`` on :class:`TracingContext` — the same
188+
``project_id`` the auth middleware scopes its permission check on (``request.state.otel``),
189+
NEVER a value the caller put in the request body. Stamping it here keeps the trust boundary at
190+
the service: the runner then trusts it because the service is trusted (mirrors
191+
:class:`~agenta.sdk.agents.connections.models.RuntimeAuthContext.project_id`). The runner uses
192+
it as the preferred project scope for session keep-alive. Best-effort: no baggage / no
193+
``project_id`` returns ``None`` and the field is simply omitted."""
194+
baggage = TracingContext.get().baggage or {}
195+
project_id = baggage.get("project_id")
196+
if not project_id:
197+
return None
198+
return RunContextProject(id=str(project_id))
199+
200+
183201
def run_context() -> Optional[RunContext]:
184202
"""Capture the run's own context for tool bindings and run-kind propagation.
185203
@@ -191,9 +209,15 @@ def run_context() -> Optional[RunContext]:
191209
Best-effort: any failure (or an entirely empty context) returns ``None`` so the run proceeds and
192210
the ``runContext`` key is simply omitted.
193211
194-
The workflow and the trace are captured as INDEPENDENT failure domains: a failure reading the
195-
workflow references must not drop an otherwise-valid ``trace`` (and vice versa), so a trace-only
196-
run still ships ``runContext.trace``."""
212+
The project, the workflow, and the trace are captured as INDEPENDENT failure domains: a failure
213+
reading one must not drop the others, so a run still ships whichever parts it holds (a
214+
trace-only run still ships ``runContext.trace``; a run with only a project scope still ships
215+
``runContext.project``)."""
216+
project = None
217+
try:
218+
project = _run_context_project()
219+
except Exception: # pylint: disable=broad-except
220+
log.warning("agent: failed to capture run-context project", exc_info=True)
197221
workflow = None
198222
try:
199223
workflow = _run_context_workflow()
@@ -204,9 +228,9 @@ def run_context() -> Optional[RunContext]:
204228
trace = _run_context_trace()
205229
except Exception: # pylint: disable=broad-except
206230
log.warning("agent: failed to capture run-context trace", exc_info=True)
207-
if workflow is None and trace is None:
231+
if project is None and workflow is None and trace is None:
208232
return None
209-
return RunContext(workflow=workflow, trace=trace)
233+
return RunContext(project=project, workflow=workflow, trace=trace)
210234

211235

212236
def record_usage(usage: Optional[Dict[str, Any]]) -> None:

sdks/python/agenta/sdk/agents/wire_models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ class WireRunContextRun(_WireModel):
202202
kind: Optional[str] = None
203203

204204

205+
class WireRunContextProject(_WireModel):
206+
"""The run's owning project identity inside ``runContext`` (mirrors ``RunContextProject``).
207+
``id`` is the SERVER-stamped project id — never a caller-supplied value. The keys stay
208+
snake_case on purpose — see ``WireRunContext``."""
209+
210+
id: Optional[str] = None
211+
212+
205213
class WireRunContext(_WireModel):
206214
"""The run's own context, delivered on ``/run`` and refreshed per turn (direct-call tools,
207215
Phase 3a; mirrors ``RunContext.to_wire``).
@@ -216,6 +224,7 @@ class WireRunContext(_WireModel):
216224
``runContext`` on the request."""
217225

218226
run: Optional[WireRunContextRun] = None
227+
project: Optional[WireRunContextProject] = None
219228
workflow: Optional[WireRunContextWorkflow] = None
220229
trace: Optional[WireRunContextTrace] = None
221230

sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
},
3232
"runContext": {
3333
"run": {"kind": "test"},
34+
"project": {"id": "proj_abc"},
3435
"workflow": {
3536
"artifact": {"id": "wf_abc"},
3637
"variant": {"id": "var_abc", "slug": "weather-agent"},

sdks/python/oss/tests/pytest/unit/agents/test_tracing.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
from types import SimpleNamespace
1010

11-
from agenta.sdk.agents import RunContextTrace, RunContextWorkflow
11+
from agenta.sdk.agents import (
12+
RunContextProject,
13+
RunContextTrace,
14+
RunContextWorkflow,
15+
)
1216

1317
from agenta.sdk.agents import tracing
1418

@@ -50,8 +54,10 @@ def boom():
5054
assert ctx.workflow == RunContextWorkflow(is_draft=True)
5155

5256

53-
def test_run_context_none_when_both_empty(monkeypatch):
54-
# No workflow identity and no trace -> no run context at all (the key is omitted on the wire).
57+
def test_run_context_none_when_all_empty(monkeypatch):
58+
# No project, no workflow identity, and no trace -> no run context at all (the key is omitted
59+
# on the wire).
60+
monkeypatch.setattr(tracing, "_run_context_project", lambda: None)
5561
monkeypatch.setattr(tracing, "_run_context_workflow", lambda: None)
5662
monkeypatch.setattr(tracing, "_run_context_trace", lambda: None)
5763
assert tracing.run_context() is None
@@ -84,3 +90,59 @@ def test_run_context_workflow_normalizes_application_references(monkeypatch):
8490
assert workflow.revision.id == "revision-id"
8591
assert workflow.revision.version == "v2"
8692
assert workflow.is_draft is False
93+
94+
95+
def test_run_context_project_stamped_from_server_baggage(monkeypatch):
96+
# The owning project id is read from the SERVER-derived request context (the authenticated
97+
# OTel baggage on TracingContext), never from anything the caller sends. This is the source
98+
# the runner trusts to scope its keep-alive pool.
99+
monkeypatch.setattr(
100+
tracing.TracingContext,
101+
"get",
102+
lambda: SimpleNamespace(baggage={"project_id": "proj-42"}),
103+
)
104+
105+
project = tracing._run_context_project()
106+
107+
assert project == RunContextProject(id="proj-42")
108+
109+
110+
def test_run_context_project_none_without_baggage(monkeypatch):
111+
# No baggage / no project_id in the request state -> no project scope; the field is omitted
112+
# and the runner falls back to the mount-derived scope.
113+
monkeypatch.setattr(
114+
tracing.TracingContext,
115+
"get",
116+
lambda: SimpleNamespace(baggage=None),
117+
)
118+
assert tracing._run_context_project() is None
119+
120+
monkeypatch.setattr(
121+
tracing.TracingContext,
122+
"get",
123+
lambda: SimpleNamespace(baggage={"other": "x"}),
124+
)
125+
assert tracing._run_context_project() is None
126+
127+
128+
def test_run_context_keeps_project_when_workflow_and_trace_fail(monkeypatch):
129+
# The project scope is its own failure domain: a run that holds only a project id (no workflow,
130+
# no trace) still ships `runContext.project` so the runner can key its keep-alive pool on it.
131+
def boom():
132+
raise RuntimeError("unavailable")
133+
134+
monkeypatch.setattr(
135+
tracing,
136+
"_run_context_project",
137+
lambda: RunContextProject(id="proj-42"),
138+
)
139+
monkeypatch.setattr(tracing, "_run_context_workflow", boom)
140+
monkeypatch.setattr(tracing, "_run_context_trace", boom)
141+
142+
ctx = tracing.run_context()
143+
assert ctx is not None
144+
assert ctx.project == RunContextProject(id="proj-42")
145+
assert ctx.workflow is None
146+
assert ctx.trace is None
147+
# The project rides the wire under the snake_case `project.id` binding namespace.
148+
assert ctx.to_wire() == {"project": {"id": "proj-42"}}

sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
PiAgentTemplate,
2929
ResolvedConnection,
3030
RunContext,
31+
RunContextProject,
3132
RunContextReference,
3233
RunContextRun,
3334
RunContextTrace,
@@ -149,6 +150,9 @@ def _pi_payload():
149150
# not run context.
150151
run_context=RunContext(
151152
run=RunContextRun(kind="test"),
153+
# The owning project id, stamped server-side (F5): the trustworthy scope the runner
154+
# prefers when keying its keep-alive pool, over the mount-derived fallback.
155+
project=RunContextProject(id="proj_abc"),
152156
workflow=RunContextWorkflow(
153157
artifact=RunContextReference(id="wf_abc"),
154158
variant=RunContextReference(id="var_abc", slug="weather-agent"),
@@ -267,6 +271,9 @@ def test_request_to_wire_pi_matches_golden(golden):
267271
# by `to_wire`. The conversation id is NOT here — it rides the top-level `sessionId`.
268272
assert payload["runContext"] == {
269273
"run": {"kind": "test"},
274+
# The server-stamped project scope (F5), snake_case inner key `project.id` — the
275+
# `$ctx.project.id` binding namespace and the runner's preferred keep-alive pool scope.
276+
"project": {"id": "proj_abc"},
270277
"workflow": {
271278
"artifact": {"id": "wf_abc"},
272279
"variant": {"id": "var_abc", "slug": "weather-agent"},

services/runner/src/engines/sandbox_agent.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,8 @@ export interface SessionEnvironment {
431431
runAgentDir: string | undefined;
432432
otlpAuthFilePath: string | undefined;
433433
mountCreds: MountCredentials | null;
434-
/** The mount's owning project id (keep-alive pool key scope); undefined when there is no mount. */
434+
/** The mount's owning project id (keep-alive pool key FALLBACK scope, preferred is
435+
* `runContext.project.id`); undefined when there is no mount. */
435436
mountProjectId?: string;
436437
// Mutable teardown/turn state shared across acquire, runTurn, and destroy.
437438
sessionDestroyRequested: boolean;
@@ -471,12 +472,14 @@ export type AcquireEnvironmentResult =
471472

472473
/**
473474
* Sign the session's durable mount up front so keep-alive can build a pool key (the mount's
474-
* owning `projectId`) and credential epoch without acquiring the whole environment. Returns
475+
* owning `projectId`, the FALLBACK project scope when the run carries no service-stamped
476+
* `runContext.project.id`) and credential epoch without acquiring the whole environment. Returns
475477
* exactly what the sign yielded: `null` when there is no session/credential to sign with, or
476478
* the sign returned no usable mount (store unconfigured, 503, ephemeral fallback). The caller
477479
* threads the result — null included — into `acquireEnvironment` as `presignedMount`, so the
478-
* mount is signed exactly once per run on every path; a null result additionally means there is
479-
* NO safe project key and the request must never park.
480+
* mount is signed exactly once per run on every path. A null result no longer forces a cold run
481+
* on its own: the request still parks when the run context supplied a project scope, and only
482+
* skips parking when NEITHER source yields one (`poolKeyFor` returns null).
480483
*/
481484
export async function resolveKeepaliveMount(
482485
request: AgentRunRequest,

services/runner/src/engines/sandbox_agent/mount.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ export interface MountCredentials {
3333
expiresAt?: string;
3434
/**
3535
* The mount's owning project id, surfaced from the sign response's `mount` object. It is the
36-
* only project scope the runner can trust for this request (the /run wire carries no project
37-
* id today), so session keep-alive keys its pool on `<projectId>:<sessionId>`. Absent when the
38-
* response omitted the mount object; keep-alive then refuses to park (no safe key source).
36+
* FALLBACK project scope for session keep-alive: the pool prefers the service-stamped
37+
* `runContext.project.id` and falls back to this mount scope when the run carries no stamped
38+
* project (see `poolKeyFor`). Absent when the response omitted the mount object; keep-alive
39+
* then parks only if the run context supplied a scope, and refuses to park when neither does.
3940
*/
4041
projectId?: string;
4142
}

0 commit comments

Comments
 (0)