computer-agent-py 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/PKG-INFO +5 -5
  2. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/README.md +3 -3
  3. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/pyproject.toml +1 -1
  4. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/__init__.py +1 -1
  5. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/_proxy/client.py +2 -2
  6. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/_proxy/query.py +3 -3
  7. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/agentos.py +91 -0
  8. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/engines/claude_agent.py +19 -2
  9. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/__init__.py +26 -0
  10. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/coordinator.py +84 -9
  11. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/remote.py +14 -0
  12. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/policy/authorizer.py +34 -4
  13. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/policy/cedar.py +2 -2
  14. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/policy/srs.py +36 -3
  15. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/policy/types.py +10 -0
  16. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/event.py +53 -3
  17. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/sinks/agentos_http.py +5 -0
  18. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/sinks/content.py +42 -5
  19. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/sinks/otel.py +324 -63
  20. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/_proxy/__init__.py +0 -0
  21. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/engines/__init__.py +0 -0
  22. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/engines/git_agent.py +0 -0
  23. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/agent_resolve_client.py +0 -0
  24. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/cas_client.py +0 -0
  25. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/errors.py +0 -0
  26. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/git_credential_client.py +0 -0
  27. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/protocol.py +0 -0
  28. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/harness/usage.py +0 -0
  29. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/identity/__init__.py +0 -0
  30. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/identity/gap.py +0 -0
  31. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/identity/passthrough.py +0 -0
  32. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/policy/__init__.py +0 -0
  33. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/policy/opa.py +0 -0
  34. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/protocol_types/__init__.py +0 -0
  35. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/py.typed +0 -0
  36. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/session_stores/__init__.py +0 -0
  37. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/session_stores/memory.py +0 -0
  38. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/substrates/__init__.py +0 -0
  39. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/substrates/local.py +0 -0
  40. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/__init__.py +0 -0
  41. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/config.py +0 -0
  42. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/middleware/__init__.py +0 -0
  43. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/middleware/guardrails.py +0 -0
  44. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/middleware/pii.py +0 -0
  45. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/pipeline.py +0 -0
  46. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/telemetry/sinks/__init__.py +0 -0
  47. {computer_agent_py-0.2.1 → computer_agent_py-0.2.3}/src/computeragent/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: computer-agent-py
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks.
5
5
  Keywords: computeragent,claude-agent-sdk,claude,agent,telemetry,otel,opentelemetry,agentos,pii
6
6
  Author: Abhi Bhat
@@ -42,9 +42,9 @@ Requires-Dist: opentelemetry-semantic-conventions>=0.48b0 ; extra == 'otel'
42
42
  Requires-Dist: httpx>=0.27 ; extra == 'remote'
43
43
  Requires-Dist: aiosqlite>=0.20,<1 ; extra == 'sqlite'
44
44
  Requires-Python: >=3.10
45
- Project-URL: Changelog, https://github.com/open-gitagent/computer-agent-py/blob/main/CHANGELOG.md
46
45
  Project-URL: Homepage, https://github.com/open-gitagent/computer-agent-py
47
46
  Project-URL: Issues, https://github.com/open-gitagent/computer-agent-py/issues
47
+ Project-URL: Changelog, https://github.com/open-gitagent/computer-agent-py/blob/main/CHANGELOG.md
48
48
  Provides-Extra: agentos
49
49
  Provides-Extra: all
50
50
  Provides-Extra: cedar
@@ -509,7 +509,7 @@ configure(
509
509
  PiiRedactor(strategy="hash", extra_patterns=[r"BADGE-\d{6}"]),
510
510
  GuardrailFilter(
511
511
  max_attribute_length=4096,
512
- tool_name_allowlist={"Read", "Glob", "Grep", "mcp__nordassist-tools__*"},
512
+ tool_name_allowlist={"Read", "Glob", "Grep", "mcp__acme-assist-tools__*"},
513
513
  cost_ceiling_usd=1.50,
514
514
  ),
515
515
  ],
@@ -551,7 +551,7 @@ opa = OpaPolicyEngine(
551
551
  authorizer = PolicyToolAuthorizer(
552
552
  engine=opa,
553
553
  principal_resolver=lambda ctx: PolicyPrincipal(id="alice", groups=["engineer"]),
554
- resource_resolver=lambda ctx: PolicyResource(agent_name="nordassist", model="claude-sonnet-4-5"),
554
+ resource_resolver=lambda ctx: PolicyResource(agent_name="acme-assist", model="claude-sonnet-4-5"),
555
555
  context_resolver=lambda ctx: {"env": "prod"},
556
556
  )
557
557
 
@@ -650,7 +650,7 @@ The proxy is a pure tap — messages are never modified or reordered. Sinks run
650
650
 
651
651
  | File | Demonstrates |
652
652
  |---|---|
653
- | [`examples/pdf_drop_in.py`](examples/pdf_drop_in.py) | The minimum drop-in change |
653
+ | [`examples/drop_in.py`](examples/drop_in.py) | The minimum drop-in change |
654
654
  | [`examples/with_otel.py`](examples/with_otel.py) | OTel pointed at a local collector |
655
655
  | [`examples/with_new_relic.py`](examples/with_new_relic.py) | OTel pointed at New Relic (just env vars) |
656
656
  | [`examples/with_datadog.py`](examples/with_datadog.py) | OTel pointed at Datadog |
@@ -452,7 +452,7 @@ configure(
452
452
  PiiRedactor(strategy="hash", extra_patterns=[r"BADGE-\d{6}"]),
453
453
  GuardrailFilter(
454
454
  max_attribute_length=4096,
455
- tool_name_allowlist={"Read", "Glob", "Grep", "mcp__nordassist-tools__*"},
455
+ tool_name_allowlist={"Read", "Glob", "Grep", "mcp__acme-assist-tools__*"},
456
456
  cost_ceiling_usd=1.50,
457
457
  ),
458
458
  ],
@@ -494,7 +494,7 @@ opa = OpaPolicyEngine(
494
494
  authorizer = PolicyToolAuthorizer(
495
495
  engine=opa,
496
496
  principal_resolver=lambda ctx: PolicyPrincipal(id="alice", groups=["engineer"]),
497
- resource_resolver=lambda ctx: PolicyResource(agent_name="nordassist", model="claude-sonnet-4-5"),
497
+ resource_resolver=lambda ctx: PolicyResource(agent_name="acme-assist", model="claude-sonnet-4-5"),
498
498
  context_resolver=lambda ctx: {"env": "prod"},
499
499
  )
500
500
 
@@ -593,7 +593,7 @@ The proxy is a pure tap — messages are never modified or reordered. Sinks run
593
593
 
594
594
  | File | Demonstrates |
595
595
  |---|---|
596
- | [`examples/pdf_drop_in.py`](examples/pdf_drop_in.py) | The minimum drop-in change |
596
+ | [`examples/drop_in.py`](examples/drop_in.py) | The minimum drop-in change |
597
597
  | [`examples/with_otel.py`](examples/with_otel.py) | OTel pointed at a local collector |
598
598
  | [`examples/with_new_relic.py`](examples/with_new_relic.py) | OTel pointed at New Relic (just env vars) |
599
599
  | [`examples/with_datadog.py`](examples/with_datadog.py) | OTel pointed at Datadog |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "computer-agent-py"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks."
5
5
  authors = [
6
6
  { name = "Abhi Bhat", email = "abhishek.bhat@lyzr.ai" },
@@ -42,7 +42,7 @@ _upstream_all = [n for n in getattr(_cas, "__all__", []) if n != "__version__"]
42
42
  for _name in _upstream_all:
43
43
  globals()[_name] = getattr(_cas, _name)
44
44
 
45
- __version__ = "0.2.1"
45
+ __version__ = "0.2.3"
46
46
  upstream_version = getattr(_cas, "__version__", "unknown")
47
47
 
48
48
  # Override the two entry points with the proxied versions. Same signatures,
@@ -143,7 +143,7 @@ class ClaudeSDKClient:
143
143
  self._upstream_session_id = real_sid
144
144
  for ev in TelemetryEvent.from_message(
145
145
  msg, session_id=self._session_id, agent_name=self._agent_name
146
- ):
146
+ ) or []: # None → non-SDK payload; the proxy only wraps claude_agent_sdk
147
147
  if self._agent_description is not None:
148
148
  ev.agent_description = self._agent_description
149
149
  await pipeline.emit(ev)
@@ -168,7 +168,7 @@ class ClaudeSDKClient:
168
168
  self._upstream_session_id = real_sid
169
169
  for ev in TelemetryEvent.from_message(
170
170
  msg, session_id=self._session_id, agent_name=self._agent_name
171
- ):
171
+ ) or []: # None → non-SDK payload; the proxy only wraps claude_agent_sdk
172
172
  if self._agent_description is not None:
173
173
  ev.agent_description = self._agent_description
174
174
  await pipeline.emit(ev)
@@ -91,7 +91,7 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
91
91
 
92
92
  for ev in TelemetryEvent.from_message(
93
93
  msg, session_id=session_id, agent_name=agent_name
94
- ):
94
+ ) or []: # None → non-SDK payload; the proxy only wraps claude_agent_sdk
95
95
  # Carry the description forward on every event so sinks that
96
96
  # group by description (or want to surface it on per-message
97
97
  # telemetry) don't have to back-reference session_started.
@@ -169,8 +169,8 @@ def _derive_agent_name(options: Any) -> str | None:
169
169
  Strategy:
170
170
  1. Explicit ``agent_name`` attribute (not in upstream today but cheap to
171
171
  honour if a fork adds it).
172
- 2. First non-empty line of ``system_prompt`` (NordAssist's prod prompt
173
- starts with "You are NordAssist..." — that line is the natural label).
172
+ 2. First non-empty line of ``system_prompt`` (prod prompts typically
173
+ start with "You are <Name>..." — that line is the natural label).
174
174
  3. ``None`` — the sinks fall back to an anonymous hash.
175
175
  """
176
176
  if options is None:
@@ -124,6 +124,97 @@ def otel_url() -> str | None:
124
124
  return _resolve("otel", "AGENTOS_OTEL_URL")
125
125
 
126
126
 
127
+ def policy_config_url(name: str) -> str | None:
128
+ """AgentOS ingest ``policy-config/{name}`` URL, derived from the ingest
129
+ endpoint (``…/ingest/events`` → ``…/ingest/policy-config/{name}``).
130
+
131
+ ``None`` when no ingest endpoint is configured."""
132
+ base = ingest_url()
133
+ if not base:
134
+ return None
135
+ from urllib.parse import quote
136
+
137
+ root = base[: -len("/events")] if base.endswith("/events") else base
138
+ return f"{root}/policy-config/{quote(name, safe='')}"
139
+
140
+
141
+ def fetch_policy_binding(
142
+ name: str, *, token: str | None = None, timeout: float = 5.0
143
+ ) -> dict[str, str] | None:
144
+ """Fetch the SRS policy *binding* for an agent name from AgentOS.
145
+
146
+ Returns ``{"kind": "srs", "policyId": …, "principalId": …}`` when the agent
147
+ has a bound policy, else ``None``. Never returns the SRS endpoint/key — those
148
+ stay server-side; the CAS fills them from its own env. Best-effort: any
149
+ network/config error logs and returns ``None`` (no policy rather than a crash).
150
+ """
151
+ url = policy_config_url(name)
152
+ if not url:
153
+ return None
154
+ try:
155
+ import httpx
156
+ except ImportError: # pragma: no cover - httpx ships with [remote]/[agentos]
157
+ logger.debug("httpx not installed; cannot fetch AgentOS policy-config")
158
+ return None
159
+
160
+ headers = {"accept": "application/json"}
161
+ key = token or cak()
162
+ if key:
163
+ headers["authorization"] = f"Bearer {key}"
164
+ try:
165
+ resp = httpx.get(url, headers=headers, timeout=timeout)
166
+ resp.raise_for_status()
167
+ doc = resp.json()
168
+ except Exception as exc: # network / DNS / TLS / bad JSON — never fatal
169
+ logger.warning("AgentOS policy-config fetch from %s failed (%s)", url, exc)
170
+ return None
171
+
172
+ policy = doc.get("policy") if isinstance(doc, dict) else None
173
+ if not isinstance(policy, dict) or not policy.get("policyId"):
174
+ return None
175
+ return {
176
+ "kind": "srs",
177
+ "policyId": str(policy["policyId"]),
178
+ "principalId": str(policy.get("principalId") or name),
179
+ }
180
+
181
+
182
+ def bind_policy(
183
+ agent_id: str, policy_id: str | None, *, token: str | None = None, timeout: float = 10.0
184
+ ) -> dict[str, str] | None:
185
+ """Attach (or detach) an SRS policy to an agent — same write the AgentOS UI's
186
+ Policy tab performs (``PUT /agents/{id}/policy``).
187
+
188
+ ``agent_id`` is the registry id from the dashboard URL; ``policy_id`` is the
189
+ policy ``_id`` from the Policies page (``None`` detaches). Persistent: future
190
+ runs of the agent (UI and SDK alike) pick the binding up automatically.
191
+ Returns the resulting binding (``{"policyId": …}``) or ``None`` when detached.
192
+ Raises ``RuntimeError`` when AgentOS is unreachable/unconfigured — a silent
193
+ no-op here would masquerade as an attached policy.
194
+ """
195
+ base = api_url()
196
+ key = token or cak()
197
+ if not base or not key:
198
+ raise RuntimeError(
199
+ "bind_policy needs AgentOS — set AGENTOS_DISCOVERY_URL (or AGENTOS_API_URL) "
200
+ "and a cak_ key (COMPUTERAGENT_HARNESS_TOKEN)."
201
+ )
202
+ import httpx
203
+ from urllib.parse import quote
204
+
205
+ resp = httpx.put(
206
+ f"{base}/agents/{quote(agent_id, safe='')}/policy",
207
+ headers={"authorization": f"Bearer {key}", "content-type": "application/json"},
208
+ json={"policy_id": policy_id},
209
+ timeout=timeout,
210
+ )
211
+ if resp.status_code >= 400:
212
+ raise RuntimeError(f"bind_policy: AgentOS returned {resp.status_code}: {resp.text[:200]}")
213
+ doc = resp.json()
214
+ binding = doc.get("binding") if isinstance(doc, dict) else None
215
+ return binding if isinstance(binding, dict) else None
216
+
217
+
127
218
  def _reset_cache_for_tests() -> None:
128
219
  """Clear the memoized discovery result (test-only)."""
129
220
  global _endpoints
@@ -100,9 +100,12 @@ def _build_options(ctx: EngineContext) -> Any:
100
100
  # Always stream partials so the harness sees mid-turn assistant text.
101
101
  payload.setdefault("include_partial_messages", True)
102
102
  # Wire policy decider into the can_use_tool slot. ``PolicyToolAuthorizer``
103
- # already matches the CanUseTool signature, so no glue.
103
+ # already matches the CanUseTool signature; the wrapper only stamps the
104
+ # harness session id onto the SDK's ToolPermissionContext (which doesn't
105
+ # carry one) so the resulting policy_decision telemetry correlates with
106
+ # this session's open OTel spans instead of landing on "unknown".
104
107
  if ctx.policy_decider is not None:
105
- payload["can_use_tool"] = ctx.policy_decider
108
+ payload["can_use_tool"] = _bind_session_id(ctx.policy_decider, ctx.session_id)
106
109
 
107
110
  options = ClaudeAgentOptions(**payload)
108
111
  # Surface harness-supplied agent identity to the proxy's
@@ -116,6 +119,20 @@ def _build_options(ctx: EngineContext) -> Any:
116
119
  return options
117
120
 
118
121
 
122
+ def _bind_session_id(decider: Any, session_id: str) -> Any:
123
+ """Wrap a ``can_use_tool`` decider so the permission context carries the
124
+ harness session id. ``ToolPermissionContext`` is a plain dataclass, so the
125
+ extra attribute attaches cleanly; if upstream ever forbids it the stamp is
126
+ skipped and the decider still runs."""
127
+
128
+ async def _can_use_tool(tool_name: str, tool_input: dict[str, Any], context: Any) -> Any:
129
+ with suppress(Exception):
130
+ context.session_id = session_id
131
+ return await decider(tool_name, tool_input, context)
132
+
133
+ return _can_use_tool
134
+
135
+
119
136
  def _is_result_message(msg: Any) -> bool:
120
137
  try:
121
138
  from claude_agent_sdk.types import ResultMessage # type: ignore
@@ -199,6 +199,11 @@ class ComputerAgent:
199
199
  # API key for the remote server, sent as `Authorization: Bearer`.
200
200
  # Falls back to the COMPUTERAGENT_HARNESS_TOKEN env var when omitted.
201
201
  harness_token: str | None = None,
202
+ # SRS policy id (the `_id` shown in the AgentOS Policies UI). When set,
203
+ # it is used directly — no AgentOS binding lookup — so you can copy the
204
+ # id from the UI and paste it here. When omitted, the policy bound to
205
+ # `agent_name` in AgentOS (the UI's Policy tab) is fetched and used.
206
+ policy_id: str | None = None,
202
207
  ) -> None:
203
208
  from .. import agentos as _agentos
204
209
 
@@ -278,6 +283,24 @@ class ComputerAgent:
278
283
  # An explicit telemetry_pipeline= still wins for callers who want
279
284
  # client-side telemetry on purpose.
280
285
  remote_pipeline = telemetry_pipeline if telemetry_pipeline is not None else Pipeline(sinks=[])
286
+ # SRS policy parity with the AgentOS UI: the UI's chat-sandbox route
287
+ # looks up the agent's bound policy and attaches it; talking to the CAS
288
+ # directly skips that. An explicit `policy_id=` kwarg wins (copy the id
289
+ # from the Policies UI); otherwise resolve the binding (by agent name)
290
+ # from AgentOS. Either way only {policyId, principalId} is forwarded —
291
+ # the CAS fills the SRS endpoint/key from its own env.
292
+ if policy_id:
293
+ remote_policy: dict[str, str] | None = {
294
+ "kind": "srs",
295
+ "policyId": policy_id,
296
+ "principalId": agent_name or "agent",
297
+ }
298
+ else:
299
+ remote_policy = (
300
+ _agentos.fetch_policy_binding(agent_name, token=harness_token)
301
+ if agent_name
302
+ else None
303
+ )
281
304
  self._coordinator: _CoordinatorLike = _build_remote_coordinator_from_kwargs(
282
305
  harness_url=harness_url,
283
306
  harness_kind=harness_kind,
@@ -296,6 +319,7 @@ class ComputerAgent:
296
319
  harness_auth=harness_auth,
297
320
  harness_token=harness_token,
298
321
  telemetry_pipeline=remote_pipeline,
322
+ policy=remote_policy,
299
323
  )
300
324
  return
301
325
 
@@ -399,6 +423,7 @@ def _build_remote_coordinator_from_kwargs(
399
423
  harness_auth: tuple[str, str] | str | None,
400
424
  harness_token: str | None,
401
425
  telemetry_pipeline: Pipeline | None,
426
+ policy: Mapping[str, Any] | None = None,
402
427
  ) -> _CoordinatorLike:
403
428
  """Map ``ComputerAgent`` kwargs → a ``RemoteCoordinator``. Imported lazily
404
429
  so the ``[remote]`` extra (httpx) is only required in remote mode."""
@@ -433,6 +458,7 @@ def _build_remote_coordinator_from_kwargs(
433
458
  auth=harness_auth,
434
459
  token=harness_token,
435
460
  telemetry_pipeline=telemetry_pipeline,
461
+ policy=policy,
436
462
  )
437
463
 
438
464
 
@@ -345,6 +345,9 @@ class HarnessCoordinator:
345
345
  """Iterate the engine, translate each event, push telemetry."""
346
346
  last_assistant_text = ""
347
347
  result_payload: Any = None
348
+ # Fired once per turn when the first streamed content chunk arrives →
349
+ # time-to-first-token. Only the OtelSink consumes it.
350
+ first_token_emitted = False
348
351
  try:
349
352
  async for ev in self._engine.start_session(ctx):
350
353
  if ev.kind == "usage_snapshot":
@@ -391,6 +394,11 @@ class HarnessCoordinator:
391
394
  if state.agent_description is not None:
392
395
  tev.agent_description = state.agent_description
393
396
  await self._emit(tev)
397
+ # An assistant_message marks the end of one LLM call;
398
+ # re-arm so the NEXT call's first streamed token fires
399
+ # its own response_first_token (per-call TTFT).
400
+ if tev.kind == "assistant_message":
401
+ first_token_emitted = False
394
402
  elif upstream_events is None:
395
403
  # from_message returned None → unrecognized type, not a
396
404
  # claude-agent-sdk message (e.g. Anthropic raw response,
@@ -404,7 +412,20 @@ class HarnessCoordinator:
404
412
  ):
405
413
  await self._emit(tev)
406
414
  # upstream_events == [] → recognised SDK type with nothing to
407
- # emit (StreamEvent, RateLimitEvent, …) skip silently.
415
+ # emit (StreamEvent, RateLimitEvent, …). Use the first
416
+ # content-bearing StreamEvent of the turn as the
417
+ # time-to-first-token signal (OtelSink-only).
418
+ elif not first_token_emitted and _is_first_token_stream_event(ev.payload):
419
+ first_token_emitted = True
420
+ with suppress(Exception):
421
+ ft_ev = TelemetryEvent(
422
+ kind="response_first_token",
423
+ session_id=state.session_id,
424
+ agent_name=state.agent_name,
425
+ )
426
+ if state.agent_description is not None:
427
+ ft_ev.agent_description = state.agent_description
428
+ await self._emit(ft_ev)
408
429
  elif ev.kind == "permission_request":
409
430
  # Engines ask the harness; the harness routes to user
410
431
  # callbacks / policy decider. The Future returned by
@@ -479,13 +500,19 @@ class HarnessCoordinator:
479
500
  ev.payload["agent_version"] = manifest.version
480
501
  if getattr(manifest, "sha", None):
481
502
  ev.payload["agent_sha"] = manifest.sha
482
- # Signal harness-mode to the AgentOS server's ingest projection.
483
- # The server writes ``source.type = "library"`` for harness-mode
484
- # docs, which fails AgentOS's ``hasResolvableSource`` check so
485
- # the UI hides the chat-sandbox button (there's no Python HTTP
486
- # harness on :8787 to proxy to). Drop-in proxy callers don't set
487
- # this flag existing ``type: "inline"`` shape is preserved.
488
- ev.payload["harness_mode"] = True
503
+ # Signal harness-mode to the AgentOS server's ingest projection, which
504
+ # then writes ``source.type = "library"`` (no server-runnable source →
505
+ # the UI hides live chat). We set it for sources the AgentOS harness
506
+ # can't reconstruct on its own (a git URL or a local path that only
507
+ # exists on this machine). We deliberately do NOT set it for an INLINE
508
+ # source: the server's ``inlineSourceFor`` rebuilds a runnable
509
+ # ``agent.yaml`` + ``CLAUDE.md`` from this event's payload
510
+ # (system_prompt / allowed_tools / max_turns / permission_mode / model),
511
+ # so an inline agent stays live-chattable in the AgentOS UI via the TS
512
+ # harness's inline loader. (Drop-in proxy callers never reach here.)
513
+ is_inline = isinstance(self._source, dict) and self._source.get("type") == "inline"
514
+ if not is_inline:
515
+ ev.payload["harness_mode"] = True
489
516
  await self._emit(ev)
490
517
 
491
518
  async def _emit(self, ev: TelemetryEvent) -> None:
@@ -525,7 +552,42 @@ class HarnessCoordinator:
525
552
  }
526
553
  if self._on_permission_request_user is not None:
527
554
  with suppress(Exception):
528
- return await self._on_permission_request_user(req)
555
+ started_at = time.monotonic()
556
+ decision = await self._on_permission_request_user(req)
557
+ # Mirror PolicyToolAuthorizer: every gate decision emits a
558
+ # policy_decision event so the OTel sink can stamp the
559
+ # decision + reason onto the live execute_tool span.
560
+ # (The policy_decider path above emits its own.)
561
+ with suppress(Exception):
562
+ await self._emit(
563
+ TelemetryEvent(
564
+ kind="policy_decision",
565
+ session_id=state.session_id,
566
+ agent_name=state.agent_name,
567
+ payload={
568
+ "engine": "on_tool_call",
569
+ "decision": (
570
+ "deny"
571
+ if isinstance(decision, dict)
572
+ and decision.get("behavior") == "deny"
573
+ else "allow"
574
+ ),
575
+ "reason": (
576
+ str(decision.get("message") or "")
577
+ if isinstance(decision, dict)
578
+ else ""
579
+ ),
580
+ "latency_ms": (time.monotonic() - started_at) * 1000.0,
581
+ "principal_id": "",
582
+ "principal_groups": [],
583
+ "tool_name": req.tool_name,
584
+ "call_id": req.call_id,
585
+ "agent_name": state.agent_name or "",
586
+ "session_id": state.session_id,
587
+ },
588
+ )
589
+ )
590
+ return decision
529
591
  # Default — allow with original input.
530
592
  return {"behavior": "allow", "updated_input": dict(req.input or {})}
531
593
 
@@ -616,6 +678,19 @@ def _maybe_assistant_text(payload: Any, current: str) -> str:
616
678
  return "".join(parts) if parts else current
617
679
 
618
680
 
681
+ def _is_first_token_stream_event(payload: Any) -> bool:
682
+ """True for a claude-agent-sdk ``StreamEvent`` carrying the first model
683
+ output of a turn — a ``content_block_start`` / ``content_block_delta`` raw
684
+ Anthropic stream event. Duck-typed (no hard import) so non-streaming engines
685
+ (gitagent) never trip it. ``message_start`` is excluded: it opens the stream
686
+ before any content, so it would understate time-to-first-token."""
687
+ if type(payload).__name__ != "StreamEvent":
688
+ return False
689
+ event = getattr(payload, "event", None)
690
+ etype = event.get("type") if isinstance(event, dict) else getattr(event, "type", None)
691
+ return etype in ("content_block_start", "content_block_delta")
692
+
693
+
619
694
  def _maybe_result_payload(payload: Any, current: Any) -> Any:
620
695
  """Track the latest ``ResultMessage``-shaped payload across the stream."""
621
696
  try:
@@ -171,6 +171,7 @@ class ComputerAgentServerTransport:
171
171
  model: str | None,
172
172
  agent_id: str | None = None,
173
173
  agent_name: str | None = None,
174
+ policy: Mapping[str, Any] | None = None,
174
175
  ) -> None:
175
176
  self._client = client
176
177
  self._engine = engine
@@ -181,6 +182,7 @@ class ComputerAgentServerTransport:
181
182
  self._model = model
182
183
  self._agent_id = agent_id
183
184
  self._agent_name = agent_name
185
+ self._policy = dict(policy) if policy else None
184
186
  self._sandbox_id: str | None = None
185
187
 
186
188
  async def ensure_open(self, lifecycle_session_id: str) -> str:
@@ -207,6 +209,11 @@ class ComputerAgentServerTransport:
207
209
  body["identity"] = {"agentId": self._agent_id}
208
210
  if self._agent_name:
209
211
  body["agentName"] = self._agent_name
212
+ # Per-tool-call SRS policy. We send only the binding (policyId/principalId);
213
+ # the CAS fills the SRS endpoint+key from its own env, so the key never
214
+ # rides the wire. Omitted entirely when the agent has no bound policy.
215
+ if self._policy:
216
+ body["policy"] = self._policy
210
217
  resp = await self._client.create_sandbox(body)
211
218
  self._sandbox_id = str(resp["sandboxId"])
212
219
  return self._sandbox_id
@@ -254,6 +261,7 @@ class RemoteCoordinator:
254
261
  auth: tuple[str, str] | str | None,
255
262
  token: str | None,
256
263
  telemetry_pipeline: Pipeline | None,
264
+ policy: Mapping[str, Any] | None = None,
257
265
  ) -> None:
258
266
  self._url = harness_url.rstrip("/")
259
267
  self._kind = harness_kind
@@ -270,6 +278,9 @@ class RemoteCoordinator:
270
278
  # _emit_safe AND sent in the CAS sandbox body (server transport) as
271
279
  # `identity.agentId` so the server tags spans + writes the session index.
272
280
  self._agent_id = agent_id
281
+ # SRS policy binding ({kind, policyId, principalId}); attached to the CAS
282
+ # sandbox body so the server builds its SrsPolicyDecider. None → no policy.
283
+ self._policy = dict(policy) if policy else None
273
284
  self._on_tool_call = on_tool_call
274
285
  self._auth = auth
275
286
  self._token = token
@@ -308,6 +319,7 @@ class RemoteCoordinator:
308
319
  model=self._model,
309
320
  agent_id=self._agent_id,
310
321
  agent_name=self._agent_name,
322
+ policy=self._policy,
311
323
  )
312
324
  else:
313
325
  opts = dict(self._options)
@@ -603,6 +615,7 @@ def build_remote_coordinator(
603
615
  auth: tuple[str, str] | str | None,
604
616
  token: str | None,
605
617
  telemetry_pipeline: Pipeline | None,
618
+ policy: Mapping[str, Any] | None = None,
606
619
  ) -> RemoteCoordinator:
607
620
  return RemoteCoordinator(
608
621
  harness_url=harness_url,
@@ -622,6 +635,7 @@ def build_remote_coordinator(
622
635
  auth=auth,
623
636
  token=token,
624
637
  telemetry_pipeline=telemetry_pipeline,
638
+ policy=policy,
625
639
  )
626
640
 
627
641
 
@@ -70,6 +70,14 @@ class PolicyToolAuthorizer:
70
70
  context: ToolPermissionContext,
71
71
  ) -> PermissionResultAllow | PermissionResultDeny:
72
72
  started_at = time.monotonic()
73
+ # Correlation ids for the telemetry event. The upstream context is
74
+ # either a ToolPermissionContext (claude-agent-sdk — carries
75
+ # ``tool_use_id``; ``session_id`` is stamped on by the harness engine)
76
+ # or the coordinator's plain dict (``{"session_id", "call_id"}``).
77
+ # Without these the policy_decision event lands on session "unknown"
78
+ # and the OTel sink can't annotate the live execute_tool span.
79
+ session_id = str(_ctx_value(context, "session_id") or "")
80
+ call_id = str(_ctx_value(context, "tool_use_id", "call_id") or "")
73
81
  input_obj = await self._build_input(tool_name, tool_input, context)
74
82
  try:
75
83
  result = await self._engine.evaluate(input_obj)
@@ -85,10 +93,14 @@ class PolicyToolAuthorizer:
85
93
  message=f"policy engine error: {exc}",
86
94
  interrupt=self._on_deny == "interrupt",
87
95
  )
88
- await self._emit_telemetry(input_obj, None, exc, started_at)
96
+ await self._emit_telemetry(
97
+ input_obj, None, exc, started_at, session_id=session_id, call_id=call_id
98
+ )
89
99
  return permission
90
100
 
91
- await self._emit_telemetry(input_obj, result, None, started_at)
101
+ await self._emit_telemetry(
102
+ input_obj, result, None, started_at, session_id=session_id, call_id=call_id
103
+ )
92
104
 
93
105
  if result.decision == PolicyDecision.ALLOW:
94
106
  return PermissionResultAllow(updated_input=tool_input)
@@ -114,13 +126,13 @@ class PolicyToolAuthorizer:
114
126
  else:
115
127
  resource = PolicyResource(
116
128
  type="agent",
117
- session_id=getattr(context, "session_id", "") or "",
129
+ session_id=str(_ctx_value(context, "session_id") or ""),
118
130
  )
119
131
  ctx: dict[str, Any]
120
132
  if self._context_resolver is not None:
121
133
  ctx = await _maybe_await(self._context_resolver(context))
122
134
  else:
123
- ctx = {"session_id": getattr(context, "session_id", "") or ""}
135
+ ctx = {"session_id": str(_ctx_value(context, "session_id") or "")}
124
136
 
125
137
  return PolicyInput(
126
138
  principal=principal,
@@ -139,6 +151,9 @@ class PolicyToolAuthorizer:
139
151
  result: Any,
140
152
  error: BaseException | None,
141
153
  started_at: float,
154
+ *,
155
+ session_id: str = "",
156
+ call_id: str = "",
142
157
  ) -> None:
143
158
  latency_ms = (time.monotonic() - started_at) * 1000.0
144
159
  try:
@@ -148,6 +163,8 @@ class PolicyToolAuthorizer:
148
163
  error=error,
149
164
  latency_ms=latency_ms,
150
165
  engine_name=getattr(self._engine, "name", type(self._engine).__name__),
166
+ session_id=session_id,
167
+ call_id=call_id,
151
168
  )
152
169
  await get_pipeline().emit(event)
153
170
  except Exception: # noqa: BLE001
@@ -159,3 +176,16 @@ async def _maybe_await(value: Any) -> Any:
159
176
  if hasattr(value, "__await__"):
160
177
  return await value
161
178
  return value
179
+
180
+
181
+ def _ctx_value(context: Any, *names: str) -> Any:
182
+ """Read a correlation field off whatever shape the upstream context has —
183
+ attribute access for ToolPermissionContext-style objects, key access for
184
+ the coordinator's plain-dict context. First non-empty wins."""
185
+ for name in names:
186
+ value = getattr(context, name, None)
187
+ if value is None and isinstance(context, dict):
188
+ value = context.get(name)
189
+ if value:
190
+ return value
191
+ return None
@@ -10,8 +10,8 @@ Translation rules:
10
10
  → ``User::"alice"`` with parents ``[Group::"engineer", ...]``.
11
11
  * ``PolicyAction(name="tool_use", tool_name="Read")``
12
12
  → ``Action::"toolUse"`` with attribute ``tool: "Read"``.
13
- * ``PolicyResource(type="agent", agent_name="nordassist")``
14
- → ``Agent::"nordassist"``.
13
+ * ``PolicyResource(type="agent", agent_name="acme-assist")``
14
+ → ``Agent::"acme-assist"``.
15
15
 
16
16
  Optional extra: ``pip install 'computer-agent-py[cedar]'``.
17
17
  """