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.
@@ -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.