cambrian-agent-sdk 0.1.0__py3-none-any.whl
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.
- cambrian_agent_sdk/__init__.py +532 -0
- cambrian_agent_sdk/_logging.py +66 -0
- cambrian_agent_sdk/_proto/__init__.py +1 -0
- cambrian_agent_sdk/_proto/cambrian_pb2.py +182 -0
- cambrian_agent_sdk/_proto/cambrian_pb2_grpc.py +1206 -0
- cambrian_agent_sdk/base.py +271 -0
- cambrian_agent_sdk/clients.py +229 -0
- cambrian_agent_sdk/daemon.py +143 -0
- cambrian_agent_sdk/errors.py +35 -0
- cambrian_agent_sdk/helpers.py +188 -0
- cambrian_agent_sdk/py.typed +0 -0
- cambrian_agent_sdk/react.py +1212 -0
- cambrian_agent_sdk/recurrence.py +86 -0
- cambrian_agent_sdk/reflection.py +62 -0
- cambrian_agent_sdk/runtime.py +223 -0
- cambrian_agent_sdk/server.py +203 -0
- cambrian_agent_sdk/tools.py +206 -0
- cambrian_agent_sdk/types.py +322 -0
- cambrian_agent_sdk/working_memory.py +985 -0
- cambrian_agent_sdk-0.1.0.dist-info/METADATA +80 -0
- cambrian_agent_sdk-0.1.0.dist-info/RECORD +23 -0
- cambrian_agent_sdk-0.1.0.dist-info/WHEEL +4 -0
- cambrian_agent_sdk-0.1.0.dist-info/licenses/LICENSE +216 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Cambrian Agent SDK — build Cambrian agents in Python with zero boilerplate.
|
|
2
|
+
|
|
3
|
+
Minimal usage::
|
|
4
|
+
|
|
5
|
+
from cambrian_agent_sdk import Agent, Capability
|
|
6
|
+
|
|
7
|
+
agent = Agent(
|
|
8
|
+
agent_id="my-agent",
|
|
9
|
+
capabilities=[Capability(name="text_generation", latency_p50_ms=3000)],
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
@agent.capability("text_generation")
|
|
13
|
+
def generate(request):
|
|
14
|
+
text = request.payload.text
|
|
15
|
+
return {"data": f"Echo: {text}".encode()}
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
agent.serve()
|
|
19
|
+
|
|
20
|
+
The agent file must be named ``*agent.py`` and live in the ``agents/`` directory
|
|
21
|
+
configured in ``config.json``. The Substrate auto-discovers it on startup.
|
|
22
|
+
|
|
23
|
+
AGENT_DESCRIPTION and AGENT_MANIFEST must be module-level literals — the
|
|
24
|
+
Substrate parses them with regex before the Python process boots. Use
|
|
25
|
+
``cambrian manifest ./my_agent.py`` to generate or update the manifest block.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
from typing import Callable, Dict, List, Optional, TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from .types import (
|
|
37
|
+
AgentResult,
|
|
38
|
+
AgentTask,
|
|
39
|
+
Capability,
|
|
40
|
+
ContextNode,
|
|
41
|
+
ContextRef,
|
|
42
|
+
ExecuteRequest,
|
|
43
|
+
ExecuteResponse,
|
|
44
|
+
Payload,
|
|
45
|
+
ProposalRequest,
|
|
46
|
+
ProposalResponse,
|
|
47
|
+
ScopeConfig,
|
|
48
|
+
SubGoal,
|
|
49
|
+
VerifyRequest,
|
|
50
|
+
VerifyResponse,
|
|
51
|
+
yield_subgoal,
|
|
52
|
+
)
|
|
53
|
+
from .base import Agent, CognitiveAgent, DeterministicAgent, DaemonAgent
|
|
54
|
+
from .tools import tool, capability, ToolRegistry, ToolSpec
|
|
55
|
+
from .react import ReActLoopError
|
|
56
|
+
from .clients import (
|
|
57
|
+
ArtifactManager,
|
|
58
|
+
ArtifactNotFound,
|
|
59
|
+
InvalidTagError,
|
|
60
|
+
MemoryClient,
|
|
61
|
+
SelfDelegationError,
|
|
62
|
+
WorkingMemory,
|
|
63
|
+
)
|
|
64
|
+
from .helpers import extract_code_block, find_step_ref, build_prompt
|
|
65
|
+
from .errors import BudgetExceededError
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
# ADR-0036 v2 trait-aligned surface
|
|
69
|
+
"Agent", # abstract base
|
|
70
|
+
"CognitiveAgent",
|
|
71
|
+
"DeterministicAgent",
|
|
72
|
+
"DaemonAgent",
|
|
73
|
+
"AgentTask",
|
|
74
|
+
"AgentResult",
|
|
75
|
+
"SubGoal",
|
|
76
|
+
"yield_subgoal",
|
|
77
|
+
"tool",
|
|
78
|
+
"capability",
|
|
79
|
+
"ToolRegistry",
|
|
80
|
+
"ToolSpec",
|
|
81
|
+
"ReActLoopError",
|
|
82
|
+
"MemoryClient",
|
|
83
|
+
"ArtifactManager",
|
|
84
|
+
"WorkingMemory",
|
|
85
|
+
"InvalidTagError",
|
|
86
|
+
"ArtifactNotFound",
|
|
87
|
+
"SelfDelegationError",
|
|
88
|
+
# shared vocabulary + helpers
|
|
89
|
+
"assemble_context",
|
|
90
|
+
"build_prompt",
|
|
91
|
+
"BudgetExceededError",
|
|
92
|
+
"Capability",
|
|
93
|
+
"ContextNode",
|
|
94
|
+
"ContextRef",
|
|
95
|
+
"ExecuteRequest",
|
|
96
|
+
"ExecuteResponse",
|
|
97
|
+
"extract_code_block",
|
|
98
|
+
"find_step_ref",
|
|
99
|
+
"Payload",
|
|
100
|
+
"ProposalRequest",
|
|
101
|
+
"ProposalResponse",
|
|
102
|
+
"ScopeConfig",
|
|
103
|
+
"SubstrateClient",
|
|
104
|
+
"VerifyRequest",
|
|
105
|
+
"VerifyResponse",
|
|
106
|
+
"configure_logging",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
logger = logging.getLogger("cambrian.agent")
|
|
110
|
+
|
|
111
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
112
|
+
"""Install SlogHandler for Substrate-compatible structured JSON logs."""
|
|
113
|
+
from ._logging import configure_logging as _cfg
|
|
114
|
+
_cfg(level=level)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SubstrateClient:
|
|
118
|
+
"""Backchannel to the Substrate for delegation calls and LTM queries.
|
|
119
|
+
|
|
120
|
+
Injected as ``self.substrate`` on every ExecuteRequest so agents can
|
|
121
|
+
sub-delegate tasks and query shared memory without gRPC boilerplate.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, substrate_addr: str = "localhost:50051", stub=None, agent_id: str = "") -> None:
|
|
125
|
+
self._addr = substrate_addr
|
|
126
|
+
self._channel = None
|
|
127
|
+
self._stub = stub # injectable for tests; otherwise lazily dialed
|
|
128
|
+
self._agent_id = agent_id
|
|
129
|
+
self._lock = threading.Lock()
|
|
130
|
+
|
|
131
|
+
def _ensure(self) -> None:
|
|
132
|
+
# Double-checked locking: avoids the lock cost on the hot path after
|
|
133
|
+
# first connection while still being safe for parallel DAG step dispatch.
|
|
134
|
+
if self._stub is not None:
|
|
135
|
+
return
|
|
136
|
+
with self._lock:
|
|
137
|
+
if self._stub is not None:
|
|
138
|
+
return
|
|
139
|
+
import grpc
|
|
140
|
+
from ._proto import cambrian_pb2_grpc
|
|
141
|
+
self._channel = grpc.insecure_channel(self._addr)
|
|
142
|
+
self._stub = cambrian_pb2_grpc.OrchestratorStub(self._channel)
|
|
143
|
+
|
|
144
|
+
def generate(
|
|
145
|
+
self,
|
|
146
|
+
session_token_id: str,
|
|
147
|
+
prompt: str,
|
|
148
|
+
max_tokens: int = 1024,
|
|
149
|
+
temperature: float = 0.7,
|
|
150
|
+
timeout_ms: int = 0,
|
|
151
|
+
) -> str:
|
|
152
|
+
"""Call the Substrate's managed LLM proxy and return the full text.
|
|
153
|
+
|
|
154
|
+
Pass ``timeout_ms=request.deadline_remaining_ms`` to propagate the Go
|
|
155
|
+
step deadline into the gRPC call. Without this, a slow LLM call will
|
|
156
|
+
block the thread after the Go side has already moved on with
|
|
157
|
+
DEADLINE_EXCEEDED, leaking a ThreadPoolExecutor slot per hung call.
|
|
158
|
+
|
|
159
|
+
Internally delegates to :meth:`generate_stream` — see that method for
|
|
160
|
+
the streaming variant (useful for > 4k-token outputs).
|
|
161
|
+
"""
|
|
162
|
+
return "".join(self.generate_stream(
|
|
163
|
+
session_token_id=session_token_id,
|
|
164
|
+
prompt=prompt,
|
|
165
|
+
max_tokens=max_tokens,
|
|
166
|
+
temperature=temperature,
|
|
167
|
+
timeout_ms=timeout_ms,
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
def generate_stream(
|
|
171
|
+
self,
|
|
172
|
+
session_token_id: str,
|
|
173
|
+
prompt: str,
|
|
174
|
+
max_tokens: int = 1024,
|
|
175
|
+
temperature: float = 0.7,
|
|
176
|
+
timeout_ms: int = 0,
|
|
177
|
+
):
|
|
178
|
+
"""Stream text chunks from the Substrate's managed LLM proxy.
|
|
179
|
+
|
|
180
|
+
Yields each text fragment as it arrives from ``GenerateViaModelStream``.
|
|
181
|
+
Use this for large outputs to avoid buffering the entire response before
|
|
182
|
+
returning — prevents ThreadPoolExecutor saturation on 8k-token replies.
|
|
183
|
+
|
|
184
|
+
Example::
|
|
185
|
+
|
|
186
|
+
for chunk in agent.substrate.generate_stream(token, prompt, timeout_ms=5000):
|
|
187
|
+
partial_output += chunk
|
|
188
|
+
"""
|
|
189
|
+
self._ensure()
|
|
190
|
+
from ._proto import cambrian_pb2
|
|
191
|
+
req = cambrian_pb2.GenerateStreamRequest(
|
|
192
|
+
session_token_id=session_token_id,
|
|
193
|
+
prompt=prompt,
|
|
194
|
+
options=cambrian_pb2.GenerateOptions(
|
|
195
|
+
max_tokens=max_tokens,
|
|
196
|
+
temperature=temperature,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
timeout_secs = (timeout_ms / 1000.0) if timeout_ms > 0 else None
|
|
200
|
+
md = [("x-agent-id", self._agent_id)] if self._agent_id else []
|
|
201
|
+
for chunk in self._stub.GenerateViaModelStream(req, timeout=timeout_secs, metadata=md):
|
|
202
|
+
if chunk.text:
|
|
203
|
+
yield chunk.text
|
|
204
|
+
|
|
205
|
+
def get_context_node(self, cid: str, session_token_id: str = "") -> Optional["ContextNode"]:
|
|
206
|
+
"""Resolve a CID from the ContentStore. Drill-down for offloaded content.
|
|
207
|
+
|
|
208
|
+
Returns a :class:`ContextNode` with ``.data`` (bytes) when found, or
|
|
209
|
+
``None`` when the CID is unknown OR the node is owned by a different
|
|
210
|
+
session (ADR-0048 D4 read-gate). Pass ``session_token_id`` to read an
|
|
211
|
+
agent's own offloaded node (R7); ownerless nodes (tool/step results) read
|
|
212
|
+
without it. Pass as ``fetch_fn`` to :func:`assemble_context`.
|
|
213
|
+
"""
|
|
214
|
+
self._ensure()
|
|
215
|
+
from ._proto import cambrian_pb2
|
|
216
|
+
req = cambrian_pb2.ContextNodeRequest(cid=cid)
|
|
217
|
+
md = [("x-session-id", session_token_id)] if session_token_id else None
|
|
218
|
+
resp = self._stub.GetContextNode(req, metadata=md)
|
|
219
|
+
if not resp.data:
|
|
220
|
+
return None
|
|
221
|
+
return ContextNode(cid=resp.cid, type=resp.type, data=bytes(resp.data), labels=list(resp.labels))
|
|
222
|
+
|
|
223
|
+
def put_context_node(self, data: str, session_token_id: str = "") -> Optional[str]:
|
|
224
|
+
"""Offload a text block to the ephemeral ContentStore, returning its CID
|
|
225
|
+
(ADR-0048 D4/R7). The kernel owner-stamps the blob with ``session_token_id``
|
|
226
|
+
so reads are gated to the same session (:meth:`get_context_node`). The blob
|
|
227
|
+
is plan-scoped (reclaimed at plan end). Returns ``None`` on failure so the
|
|
228
|
+
working-memory offloader degrades to rendering the block verbatim.
|
|
229
|
+
"""
|
|
230
|
+
self._ensure()
|
|
231
|
+
from ._proto import cambrian_pb2
|
|
232
|
+
payload = data.encode("utf-8") if isinstance(data, str) else bytes(data)
|
|
233
|
+
req = cambrian_pb2.PutContextNodeRequest(data=payload, node_type="agent_offload")
|
|
234
|
+
md = [("x-session-id", session_token_id)] if session_token_id else None
|
|
235
|
+
try:
|
|
236
|
+
resp = self._stub.PutContextNode(req, metadata=md)
|
|
237
|
+
return resp.cid or None
|
|
238
|
+
except Exception: # noqa: BLE001 — degrade to verbatim, never crash the loop
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def ask(self, query: str, top_k: int = 5) -> List[Dict]:
|
|
242
|
+
"""Query the Substrate's shared LTM (pgvector documents table).
|
|
243
|
+
|
|
244
|
+
Returns a list of ``{"text": str, "score": float, "metadata": str}`` dicts.
|
|
245
|
+
Results are filtered by the Substrate's ACL — agents can only read
|
|
246
|
+
documents they are permitted to access.
|
|
247
|
+
"""
|
|
248
|
+
self._ensure()
|
|
249
|
+
from ._proto import cambrian_pb2
|
|
250
|
+
req = cambrian_pb2.MemoryRequest(query=query, top_k=top_k)
|
|
251
|
+
resp = self._stub.QueryMemory(req)
|
|
252
|
+
return [{"text": r.text, "score": r.score, "metadata": r.metadata} for r in resp.results]
|
|
253
|
+
|
|
254
|
+
def execute_tool(
|
|
255
|
+
self,
|
|
256
|
+
tool_name: str,
|
|
257
|
+
args_json: str = "",
|
|
258
|
+
session_token_id: str = "",
|
|
259
|
+
step_index: int = 0,
|
|
260
|
+
timeout_ms: int = 0,
|
|
261
|
+
task_id: str = "",
|
|
262
|
+
) -> Dict:
|
|
263
|
+
"""Invoke a kernel-owned system tool (ADR-0039). The agent marshals the
|
|
264
|
+
args (``args_json``) in its think() loop; the kernel authorizes (grant +
|
|
265
|
+
resource policy + scope + approval) and runs the tool in a confined
|
|
266
|
+
process. Returns the raw response as a dict — a denial or error is data,
|
|
267
|
+
never an exception — so the reasoning loop degrades instead of crashing.
|
|
268
|
+
"""
|
|
269
|
+
self._ensure()
|
|
270
|
+
from ._proto import cambrian_pb2
|
|
271
|
+
|
|
272
|
+
req = cambrian_pb2.ExecuteToolRequest(
|
|
273
|
+
tool_name=tool_name,
|
|
274
|
+
args_json=args_json or "{}",
|
|
275
|
+
session_token_id=session_token_id,
|
|
276
|
+
step_index=step_index,
|
|
277
|
+
)
|
|
278
|
+
timeout_secs = (timeout_ms / 1000.0) if timeout_ms > 0 else None
|
|
279
|
+
md = [("x-agent-id", self._agent_id)] if self._agent_id else []
|
|
280
|
+
if task_id:
|
|
281
|
+
md.append(("x-task-id", task_id)) # ADR-0049 D3: per-step correlation key
|
|
282
|
+
resp = self._stub.ExecuteTool(req, timeout=timeout_secs, metadata=md)
|
|
283
|
+
return {
|
|
284
|
+
"result_json": resp.result_json,
|
|
285
|
+
"result_cid": resp.result_cid,
|
|
286
|
+
"denied": resp.denied,
|
|
287
|
+
"deny_reason": resp.deny_reason,
|
|
288
|
+
"error": resp.error,
|
|
289
|
+
"arg_hash": resp.arg_hash,
|
|
290
|
+
"result_hash": resp.result_hash,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def embed(self, text: str) -> List[float]:
|
|
294
|
+
"""Embed text into a vector via the kernel embedder (ADR-0041) — used by the
|
|
295
|
+
agent's Local Recurrent Workspace to relevance-rank its episodic buffer.
|
|
296
|
+
|
|
297
|
+
Read-only; the single minimal kernel surface LRW adds. Degrades to an empty
|
|
298
|
+
list on any failure so ranking falls back to recency rather than crashing
|
|
299
|
+
the loop.
|
|
300
|
+
"""
|
|
301
|
+
self._ensure()
|
|
302
|
+
from ._proto import cambrian_pb2
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
resp = self._stub.Embed(cambrian_pb2.EmbedRequest(text=text))
|
|
306
|
+
except Exception: # noqa: BLE001 — degrade to recency ranking, never crash
|
|
307
|
+
return []
|
|
308
|
+
return list(resp.vector)
|
|
309
|
+
|
|
310
|
+
def list_tools(
|
|
311
|
+
self, query: str = "", k: int = 0,
|
|
312
|
+
names: Optional[List[str]] = None, full: bool = False,
|
|
313
|
+
) -> List[Dict]:
|
|
314
|
+
"""Return the system tools this agent may invoke (ADR-0039), for the ReAct
|
|
315
|
+
prompt menu. The kernel resolves the agent's grants from the x-agent-id
|
|
316
|
+
metadata and honors the unrestricted bypass; the list is **advisory** —
|
|
317
|
+
``execute_tool`` still authorizes each call kernel-side. Degrades to an
|
|
318
|
+
empty list on any RPC failure (e.g. a kernel without a tool registry), so
|
|
319
|
+
the reasoning loop never crashes for lack of a tool plane.
|
|
320
|
+
|
|
321
|
+
ADR-0044 semantic retrieval: pass ``query`` (and optional ``k``) to get
|
|
322
|
+
only the task-relevant tools (kernel-ranked) instead of the full registry —
|
|
323
|
+
the query rides x-tool-query / x-tool-k metadata. No query ⇒ the full menu.
|
|
324
|
+
|
|
325
|
+
ADR-0045 two-tier disclosure: by default the menu is Tier-1 (a terse
|
|
326
|
+
one-line summary + arg names). Pass ``names`` + ``full=True`` (the
|
|
327
|
+
``describe_tool`` path) to fetch Tier-2 (full description + full arg
|
|
328
|
+
schema) for the named granted tools only — ungranted names are absent.
|
|
329
|
+
These ride x-tool-names / x-tool-full metadata.
|
|
330
|
+
"""
|
|
331
|
+
self._ensure()
|
|
332
|
+
from ._proto import cambrian_pb2
|
|
333
|
+
|
|
334
|
+
md = [("x-agent-id", self._agent_id)] if self._agent_id else []
|
|
335
|
+
if query:
|
|
336
|
+
md.append(("x-tool-query", query))
|
|
337
|
+
if k > 0:
|
|
338
|
+
md.append(("x-tool-k", str(k)))
|
|
339
|
+
if names:
|
|
340
|
+
md.append(("x-tool-names", ",".join(names)))
|
|
341
|
+
if full:
|
|
342
|
+
md.append(("x-tool-full", "true"))
|
|
343
|
+
try:
|
|
344
|
+
resp = self._stub.ListTools(cambrian_pb2.ListToolsRequest(), metadata=md)
|
|
345
|
+
except Exception: # noqa: BLE001 — degrade to an empty menu, never crash
|
|
346
|
+
return []
|
|
347
|
+
return [
|
|
348
|
+
{
|
|
349
|
+
"name": t.name,
|
|
350
|
+
"description": t.description,
|
|
351
|
+
"schema_json": t.schema_json,
|
|
352
|
+
"dangerous": t.dangerous,
|
|
353
|
+
}
|
|
354
|
+
for t in resp.tools
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
def list_skills(
|
|
358
|
+
self, query: str = "", k: int = 0,
|
|
359
|
+
names: Optional[List[str]] = None, full: bool = False,
|
|
360
|
+
session_token_id: str = "",
|
|
361
|
+
) -> List[Dict]:
|
|
362
|
+
"""Return the system skills this agent may load (ADR-0046), for the agent's
|
|
363
|
+
[skills] menu section. The kernel resolves grants/scope from x-agent-id and
|
|
364
|
+
gates visibility by the agent's effective scope. Degrades to an empty list
|
|
365
|
+
on any RPC failure, so the loop never crashes for lack of a skill plane.
|
|
366
|
+
|
|
367
|
+
Tier-1 (summary) by default; pass ``names`` + ``full=True`` (the use_skill
|
|
368
|
+
path) to fetch Tier-2 (full instructions + bundled tool grants) for the
|
|
369
|
+
named skills. query/k/names/full ride x-skill-* metadata (mirroring tools).
|
|
370
|
+
Agent-local skills are NOT served here — they live in the SDK.
|
|
371
|
+
"""
|
|
372
|
+
self._ensure()
|
|
373
|
+
from ._proto import cambrian_pb2
|
|
374
|
+
|
|
375
|
+
md = [("x-agent-id", self._agent_id)] if self._agent_id else []
|
|
376
|
+
if query:
|
|
377
|
+
md.append(("x-skill-query", query))
|
|
378
|
+
if k > 0:
|
|
379
|
+
md.append(("x-skill-k", str(k)))
|
|
380
|
+
if names:
|
|
381
|
+
md.append(("x-skill-names", ",".join(names)))
|
|
382
|
+
if full:
|
|
383
|
+
md.append(("x-skill-full", "true"))
|
|
384
|
+
if session_token_id:
|
|
385
|
+
md.append(("x-session-token", session_token_id)) # ADR-0046 D6: run key for grant activation
|
|
386
|
+
try:
|
|
387
|
+
resp = self._stub.ListSkills(cambrian_pb2.ListSkillsRequest(), metadata=md)
|
|
388
|
+
except Exception: # noqa: BLE001 — degrade to an empty menu, never crash
|
|
389
|
+
return []
|
|
390
|
+
return [
|
|
391
|
+
{
|
|
392
|
+
"name": sk.name,
|
|
393
|
+
"description": sk.description,
|
|
394
|
+
"instructions": sk.instructions,
|
|
395
|
+
"tool_grants": list(sk.tool_grants),
|
|
396
|
+
}
|
|
397
|
+
for sk in resp.skills
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
def execute(self, description: str, session_token_id: str = "", target: Optional[str] = None, timeout_ms: int = 0) -> str:
|
|
401
|
+
"""Delegate a natural-language task to the Planner (ADR-0036 user story 11).
|
|
402
|
+
|
|
403
|
+
Self-delegation is refused (recursion guard). Sub-plan tokens are charged to
|
|
404
|
+
the **parent step's** session token by propagating ``session_token_id`` into
|
|
405
|
+
the delegated handoff — delegation cannot bypass the budget (ADR-0018).
|
|
406
|
+
"""
|
|
407
|
+
from .clients import SelfDelegationError
|
|
408
|
+
|
|
409
|
+
if target is not None and target == self._agent_id and self._agent_id:
|
|
410
|
+
raise SelfDelegationError(self._agent_id)
|
|
411
|
+
self._ensure()
|
|
412
|
+
from ._proto import cambrian_pb2
|
|
413
|
+
|
|
414
|
+
req = cambrian_pb2.Handoff(
|
|
415
|
+
from_agent=self._agent_id,
|
|
416
|
+
to_agent=target or "",
|
|
417
|
+
payload=cambrian_pb2.Object(type="text", data=description.encode("utf-8")),
|
|
418
|
+
metadata={"_session_token_id": session_token_id, "_delegated": "true"},
|
|
419
|
+
)
|
|
420
|
+
timeout = (timeout_ms / 1000.0) if timeout_ms and timeout_ms > 0 else None
|
|
421
|
+
resp = self._stub.Execute(req, timeout=timeout)
|
|
422
|
+
return resp.payload.data.decode("utf-8", errors="replace")
|
|
423
|
+
|
|
424
|
+
# ── assemble_context ─────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def assemble_context(
|
|
428
|
+
refs: List["ContextRef"],
|
|
429
|
+
min_precision: float = 0.5,
|
|
430
|
+
max_tokens: int = 800,
|
|
431
|
+
fetch_fn: Optional[Callable] = None,
|
|
432
|
+
fetch_threshold: float = 0.7,
|
|
433
|
+
) -> str:
|
|
434
|
+
"""Build a prompt-ready context string from a Global Workspace working set.
|
|
435
|
+
|
|
436
|
+
ADR-0022 Phase 3 SDK helper. Encapsulates ref ranking, precision filtering,
|
|
437
|
+
ContentStore fetching, and token budgeting so agent authors write one line:
|
|
438
|
+
|
|
439
|
+
context_str = assemble_context(
|
|
440
|
+
request.working_memory,
|
|
441
|
+
min_precision=0.5,
|
|
442
|
+
max_tokens=800,
|
|
443
|
+
fetch_fn=agent.substrate.get_context_node,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
Sort order: ``activation × precision`` descending (structural × semantic).
|
|
447
|
+
BFS nodes with ``precision == -1.0`` are treated as unknown — they require
|
|
448
|
+
``fetch_fn`` to resolve; without it they are skipped.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
refs: The ``working_memory`` list from an ``ExecuteRequest``.
|
|
452
|
+
min_precision: Skip refs whose precision is below this threshold.
|
|
453
|
+
Ignored for refs with precision==-1.0 and fetch_fn provided.
|
|
454
|
+
max_tokens: Approximate token budget (chars ÷ 4). Stops adding chunks
|
|
455
|
+
when budget is exhausted.
|
|
456
|
+
fetch_fn: Optional callable ``(cid: str) -> node`` where ``node.data``
|
|
457
|
+
is bytes. When None, snippet-only degraded mode is used.
|
|
458
|
+
fetch_fn errors fall back to snippet rather than raising.
|
|
459
|
+
fetch_threshold: Minimum precision to trigger a full fetch. Refs between
|
|
460
|
+
min_precision and fetch_threshold use their snippet.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
A multi-line string of ``[type] content`` blocks, or ``""`` when empty.
|
|
464
|
+
"""
|
|
465
|
+
if not refs:
|
|
466
|
+
return ""
|
|
467
|
+
|
|
468
|
+
def _score(r: "ContextRef") -> float:
|
|
469
|
+
if r.precision < 0:
|
|
470
|
+
return r.activation * 0.0 # unknown precision → sort to end
|
|
471
|
+
return r.activation * r.precision
|
|
472
|
+
|
|
473
|
+
ranked = sorted(refs, key=_score, reverse=True)
|
|
474
|
+
|
|
475
|
+
parts: List[str] = []
|
|
476
|
+
tokens_used = 0
|
|
477
|
+
|
|
478
|
+
for r in ranked:
|
|
479
|
+
# Skip refs below precision threshold (unknown precision requires fetch_fn).
|
|
480
|
+
if r.precision >= 0 and r.precision < min_precision:
|
|
481
|
+
continue
|
|
482
|
+
if r.precision < 0 and fetch_fn is None:
|
|
483
|
+
continue # BFS node, no fetch — cannot determine precision
|
|
484
|
+
|
|
485
|
+
# Resolve content: full fetch or snippet.
|
|
486
|
+
text = r.snippet
|
|
487
|
+
if fetch_fn is not None and (r.precision < 0 or r.precision >= fetch_threshold):
|
|
488
|
+
try:
|
|
489
|
+
node = fetch_fn(r.cid)
|
|
490
|
+
if node is not None and hasattr(node, "data"):
|
|
491
|
+
fetched = node.data
|
|
492
|
+
if isinstance(fetched, bytes):
|
|
493
|
+
fetched = fetched.decode("utf-8", errors="replace")
|
|
494
|
+
text = fetched
|
|
495
|
+
except Exception:
|
|
496
|
+
pass # degraded: use snippet
|
|
497
|
+
|
|
498
|
+
if not text:
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
budget = (max_tokens - tokens_used) * 4
|
|
502
|
+
chunk = text[:budget] if budget > 0 else ""
|
|
503
|
+
if not chunk:
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
label = r.type or "context"
|
|
507
|
+
# REQ-AGENT-CTX-2: structured XML fact blocks with precision attribute.
|
|
508
|
+
# When the ref is offloaded (has a cid), the rendered chunk is only the head of
|
|
509
|
+
# the full body — surface the cid so the agent can pass it BY REFERENCE
|
|
510
|
+
# (`{"$cid": …}`) into a tool arg (e.g. write_file content) and the kernel
|
|
511
|
+
# resolves the WHOLE content server-side. Without this the agent only ever sees
|
|
512
|
+
# the truncated head of a prior step's output and is forced to regenerate it.
|
|
513
|
+
marker = f" [full content cid:{r.cid}]" if getattr(r, "cid", "") else ""
|
|
514
|
+
parts.append(f'<fact precision="{r.precision:.2f}">{chunk}{marker}</fact>')
|
|
515
|
+
tokens_used += len(chunk) // 4
|
|
516
|
+
if tokens_used >= max_tokens:
|
|
517
|
+
break
|
|
518
|
+
|
|
519
|
+
result = "\n".join(parts)
|
|
520
|
+
logger.info(
|
|
521
|
+
"assemble_context_done",
|
|
522
|
+
extra={
|
|
523
|
+
"cid_count": len(refs),
|
|
524
|
+
"tokens_used": tokens_used,
|
|
525
|
+
"parts_count": len(parts),
|
|
526
|
+
"degraded_mode": fetch_fn is None,
|
|
527
|
+
},
|
|
528
|
+
)
|
|
529
|
+
return result
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Structured logging for Cambrian agents.
|
|
2
|
+
|
|
3
|
+
``configure_logging()`` installs a handler that writes slog-compatible JSON to
|
|
4
|
+
stdout. The Substrate reads agent stdout/stderr and forwards JSON lines to its
|
|
5
|
+
own slog stream, giving a unified cross-language log timeline correlated by
|
|
6
|
+
``task_id``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
|
|
17
|
+
_task_id_var: ContextVar[str] = ContextVar("task_id", default="")
|
|
18
|
+
_agent_id_var: ContextVar[str] = ContextVar("agent_id", default="")
|
|
19
|
+
|
|
20
|
+
_LEVEL_MAP = {
|
|
21
|
+
logging.DEBUG: "debug",
|
|
22
|
+
logging.INFO: "info",
|
|
23
|
+
logging.WARNING: "warn",
|
|
24
|
+
logging.ERROR: "error",
|
|
25
|
+
logging.CRITICAL: "error",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SlogHandler(logging.Handler):
|
|
30
|
+
"""Formats Python log records as slog-compatible JSON for Substrate pipe reading."""
|
|
31
|
+
|
|
32
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
33
|
+
entry = {
|
|
34
|
+
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
|
|
35
|
+
"level": _LEVEL_MAP.get(record.levelno, "info"),
|
|
36
|
+
"msg": self.format(record),
|
|
37
|
+
"logger": record.name,
|
|
38
|
+
}
|
|
39
|
+
task_id = _task_id_var.get()
|
|
40
|
+
agent_id = _agent_id_var.get()
|
|
41
|
+
if task_id:
|
|
42
|
+
entry["task_id"] = task_id
|
|
43
|
+
if agent_id:
|
|
44
|
+
entry["agent_id"] = agent_id
|
|
45
|
+
sys.stdout.write(json.dumps(entry) + "\n")
|
|
46
|
+
sys.stdout.flush()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def configure_logging(level: int = logging.INFO, agent_id: str = "") -> None:
|
|
50
|
+
"""Install SlogHandler on the root logger.
|
|
51
|
+
|
|
52
|
+
Call this once at agent startup before ``agent.serve()``.
|
|
53
|
+
"""
|
|
54
|
+
if agent_id:
|
|
55
|
+
_agent_id_var.set(agent_id)
|
|
56
|
+
root = logging.getLogger()
|
|
57
|
+
root.setLevel(level)
|
|
58
|
+
root.handlers.clear()
|
|
59
|
+
root.addHandler(SlogHandler())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_task_context(task_id: str, agent_id: str = "") -> None:
|
|
63
|
+
"""Set per-task context vars. Called by the SDK at on_execute entry."""
|
|
64
|
+
_task_id_var.set(task_id)
|
|
65
|
+
if agent_id:
|
|
66
|
+
_agent_id_var.set(agent_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Auto-generated proto stubs — checked in, no protoc required for users.
|