computer-agent-py 0.2.1__tar.gz → 0.2.2__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.
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/PKG-INFO +2 -2
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/pyproject.toml +1 -1
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/_proxy/client.py +2 -2
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/_proxy/query.py +1 -1
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/agentos.py +91 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/engines/claude_agent.py +19 -2
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/__init__.py +26 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/coordinator.py +84 -9
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/remote.py +14 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/policy/authorizer.py +34 -4
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/policy/srs.py +36 -3
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/policy/types.py +10 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/event.py +53 -3
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/sinks/agentos_http.py +5 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/sinks/content.py +42 -5
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/sinks/otel.py +324 -63
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/README.md +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/_proxy/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/engines/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/engines/git_agent.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/agent_resolve_client.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/cas_client.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/errors.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/git_credential_client.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/protocol.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/usage.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/identity/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/identity/gap.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/identity/passthrough.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/policy/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/policy/cedar.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/policy/opa.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/protocol_types/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/py.typed +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/session_stores/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/session_stores/memory.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/substrates/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/substrates/local.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/config.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/middleware/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/middleware/guardrails.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/middleware/pii.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/pipeline.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/telemetry/sinks/__init__.py +0 -0
- {computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/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.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "computer-agent-py"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
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" },
|
|
@@ -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.
|
|
@@ -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
|
{computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/engines/claude_agent.py
RENAMED
|
@@ -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
|
|
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
|
|
{computer_agent_py-0.2.1 → computer_agent_py-0.2.2}/src/computeragent/harness/coordinator.py
RENAMED
|
@@ -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, …)
|
|
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
|
-
#
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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=
|
|
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":
|
|
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
|
|
@@ -161,12 +161,29 @@ class SrsPolicyEngine:
|
|
|
161
161
|
"opa_guardrail": doc.get("opa_guardrail"),
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
def _mode(self) -> str:
|
|
165
|
+
"""Guardrail enforcement mode (``enforce``/``audit``) from the fetched
|
|
166
|
+
config — OPA's ``mode`` preferred, then Cedar's, default ``enforce``.
|
|
167
|
+
Mirrors the TS SrsPolicyDecider.mode()."""
|
|
168
|
+
if not self._guardrails:
|
|
169
|
+
return "enforce"
|
|
170
|
+
for key in ("opa_guardrail", "cedar_guardrail"):
|
|
171
|
+
g = self._guardrails.get(key)
|
|
172
|
+
if isinstance(g, dict) and g.get("enabled") is not False:
|
|
173
|
+
m = g.get("mode")
|
|
174
|
+
if isinstance(m, str):
|
|
175
|
+
return "audit" if m == "audit" else "enforce"
|
|
176
|
+
return "enforce"
|
|
177
|
+
|
|
164
178
|
async def evaluate(self, input: PolicyInput) -> PolicyResult:
|
|
165
179
|
try:
|
|
166
180
|
await self._load_policy()
|
|
167
181
|
if self._inert:
|
|
168
|
-
return PolicyResult(
|
|
182
|
+
return PolicyResult(
|
|
183
|
+
decision=PolicyDecision.ALLOW, engine="srs", outcome="allowed"
|
|
184
|
+
)
|
|
169
185
|
assert self._guardrails is not None
|
|
186
|
+
mode = self._mode()
|
|
170
187
|
body = {
|
|
171
188
|
"cedar_guardrail": self._guardrails.get("cedar_guardrail"),
|
|
172
189
|
"opa_guardrail": self._guardrails.get("opa_guardrail"),
|
|
@@ -183,20 +200,36 @@ class SrsPolicyEngine:
|
|
|
183
200
|
)
|
|
184
201
|
resp.raise_for_status()
|
|
185
202
|
out = resp.json()
|
|
203
|
+
# SRS enforces the mode in `allowed` (audit ⇒ always true). Derive
|
|
204
|
+
# the trace-facing outcome; a violation is a real deny OR an
|
|
205
|
+
# audit-mode finding SRS surfaced via denied_by/reason.
|
|
186
206
|
allowed = bool(out.get("allowed"))
|
|
207
|
+
reason = out.get("reason", "") or ""
|
|
208
|
+
violation = (not allowed) or bool(out.get("denied_by")) or bool(reason)
|
|
209
|
+
if mode == "audit":
|
|
210
|
+
outcome = "audit_violation" if violation else "audit_clean"
|
|
211
|
+
else:
|
|
212
|
+
outcome = "allowed" if allowed else "blocked"
|
|
187
213
|
return PolicyResult(
|
|
188
214
|
decision=PolicyDecision.ALLOW if allowed else PolicyDecision.DENY,
|
|
189
|
-
reason=
|
|
215
|
+
reason=reason,
|
|
190
216
|
engine="srs",
|
|
217
|
+
mode=mode,
|
|
218
|
+
outcome=outcome,
|
|
219
|
+
policy_id=self._policy_id,
|
|
191
220
|
)
|
|
192
221
|
except Exception as exc: # noqa: BLE001 - SRS errors fail per mode (default closed)
|
|
193
222
|
logger.warning("SRS policy evaluation failed (%s mode): %s", self._fail_mode, exc)
|
|
194
223
|
if self._fail_mode == "allow":
|
|
195
|
-
return PolicyResult(
|
|
224
|
+
return PolicyResult(
|
|
225
|
+
decision=PolicyDecision.ALLOW, engine="srs", outcome="error"
|
|
226
|
+
)
|
|
196
227
|
return PolicyResult(
|
|
197
228
|
decision=PolicyDecision.DENY,
|
|
198
229
|
reason=f"SRS error (fail-closed): {exc}",
|
|
199
230
|
engine="srs",
|
|
231
|
+
outcome="error",
|
|
232
|
+
policy_id=self._policy_id,
|
|
200
233
|
)
|
|
201
234
|
|
|
202
235
|
async def aclose(self) -> None:
|
|
@@ -93,12 +93,22 @@ class PolicyResult:
|
|
|
93
93
|
|
|
94
94
|
Obligations are advisory in v0.2 (not enforced); kept on the result so
|
|
95
95
|
sinks / audit logs can surface them.
|
|
96
|
+
|
|
97
|
+
``mode``/``outcome`` mirror the TS ``PolicyDecision`` so the OtelSink can
|
|
98
|
+
emit the same first-class ``policy`` span. ``mode`` is the guardrail's
|
|
99
|
+
enforcement mode (``enforce``/``audit``); ``outcome`` is the trace-facing
|
|
100
|
+
label (``allowed``/``blocked``/``audit_violation``/``audit_clean``/``error``).
|
|
101
|
+
Both optional — engines that don't distinguish modes leave them ``None`` and
|
|
102
|
+
the sink falls back to deriving the outcome from ``decision``.
|
|
96
103
|
"""
|
|
97
104
|
|
|
98
105
|
decision: PolicyDecision
|
|
99
106
|
reason: str = ""
|
|
100
107
|
obligations: list[dict[str, Any]] = field(default_factory=list)
|
|
101
108
|
engine: str = "" # populated by the concrete engine ("opa" | "cedar")
|
|
109
|
+
mode: str | None = None # "enforce" | "audit"
|
|
110
|
+
outcome: str | None = None # allowed | blocked | audit_violation | audit_clean | error
|
|
111
|
+
policy_id: str | None = None
|
|
102
112
|
|
|
103
113
|
|
|
104
114
|
class PolicyEngine(Protocol):
|