sectr 0.0.4__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.
sectr/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """Sectr Python SDK: framework-agnostic agent deployment.
2
+
3
+ Core (framework-free): `AgentApp` (route registry), `SessionContext` (the
4
+ handler argument), `sectr.events` (typed events mirroring
5
+ schema/event.schema.json), `sectr.tools` (`@tool` registry + approval
6
+ gating). Handlers are async generators of events; the FastAPI server
7
+ adapter (`sectr.server`) speaks the runner invoke contract, and framework
8
+ adapters (`sectr.adapters.*`) wrap native framework objects into handlers.
9
+
10
+ Contracts: `../schema/` is the single source of truth — see `schema/PLAN.md`
11
+ and `sdk-python/PLAN.md`.
12
+ """
13
+
14
+ from .app import AgentApp, Handler, Route
15
+ from .context import SessionContext
16
+ from .events import (
17
+ Envelope,
18
+ Error,
19
+ MessageDelta,
20
+ MessageEnd,
21
+ MessageStart,
22
+ NoMoreActions,
23
+ RunnerEvent,
24
+ TranscriptCompacted,
25
+ ToolApprovalRequired,
26
+ ToolCall,
27
+ ToolResult,
28
+ NoMoreActions,
29
+ )
30
+ from .tools import ApprovalRequired, Tool, tool
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "AgentApp",
36
+ "ApprovalRequired",
37
+ "Envelope",
38
+ "Error",
39
+ "Handler",
40
+ "MessageDelta",
41
+ "MessageEnd",
42
+ "MessageStart",
43
+ "Route",
44
+ "RunnerEvent",
45
+ "SessionContext",
46
+ "Tool",
47
+ "ToolApprovalRequired",
48
+ "ToolCall",
49
+ "ToolResult",
50
+ "NoMoreActions",
51
+ "TranscriptCompacted",
52
+ "tool",
53
+ ]
@@ -0,0 +1,12 @@
1
+ """Framework adapters (optional extras). Each adapter is a handler factory:
2
+ it takes the framework's native object and returns a
3
+ `(SessionContext) -> AsyncIterator[RunnerEvent]` handler, so registration is
4
+ one line per app:
5
+
6
+ app.add_route("/chat", openai_agents(agent))
7
+ app.add_route("/chat", langgraph(graph))
8
+ app.add_route("/chat", claude_code(options))
9
+
10
+ Mapping details (pause/resume semantics, transcript reconstruction) are
11
+ documented per module and pinned by the variant sketches in `examples/`.
12
+ """
@@ -0,0 +1,45 @@
1
+ """Claude Code adapter (`sectr[claude-code]` extra) — the near-black-box
2
+ case: the CLI owns its loop, tools, and permissions.
3
+
4
+ `claude_code(**options)` returns a handler that (see
5
+ `examples/claude-code/main.py`; session-memory mapping decided in
6
+ `examples/claude-code/PLAN.md`):
7
+
8
+ - runs the `claude` CLI (via claude-code-sdk) with `--resume` keyed to our
9
+ session_id (option (b): the CLI's workspace state persists across turns;
10
+ the journal stays authoritative for approvals, SSE, audit, transcript);
11
+ - assistant text → MESSAGE_*; ToolUseBlock → TOOL_CALL; ToolResultBlock →
12
+ TOOL_RESULT; result message → NO_MORE_ACTIONS (turn over);
13
+ - pause: the CLI's `can_use_tool` permission callback fires → the adapter
14
+ captures (tool_name, input), terminates the subprocess, journals
15
+ TOOL_APPROVAL_REQUIRED as the final frame (turn suspended, no sentinel);
16
+ - resume: `--resume` the same session; when the CLI re-issues the gated
17
+ tool and asks permission, the adapter answers from
18
+ `ctx.decision` (allow/deny). At most ONE pending approval per
19
+ session is possible by the turn model.
20
+
21
+ CI note: the `claude` binary is a shim script emitting canned JSONL
22
+ (examples/claude-code/tests/shim_claude.py). Implementation pending.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import AsyncIterator
28
+ from typing import Any
29
+
30
+ from ..app import Handler
31
+ from ..context import SessionContext
32
+ from ..events import RunnerEvent
33
+
34
+ __all__ = ["claude_code"]
35
+
36
+
37
+ def claude_code(**options: Any) -> Handler:
38
+ """Handler factory wrapping claude-code-sdk options. Implementation
39
+ pending."""
40
+
41
+ async def handler(ctx: SessionContext) -> AsyncIterator[RunnerEvent]:
42
+ raise NotImplementedError("sectr.adapters.claude_code")
43
+ yield # pragma: no cover - makes this an async generator
44
+
45
+ return handler
@@ -0,0 +1,80 @@
1
+ """LangGraph adapter (`sectr[langgraph]` extra).
2
+
3
+ `langgraph(graph)` returns a handler that (see `examples/langgraph/main.py`):
4
+
5
+ - normal turn: transcript → `{"messages": [...]}` state; `graph.astream`
6
+ (stream_mode="messages") → MESSAGE_* / TOOL_CALL / TOOL_RESULT;
7
+ stream end → NO_MORE_ACTIONS (turn over). No LangGraph checkpointer — the journal
8
+ is the checkpoint.
9
+ - pause: the sectr-gated tool raises `ApprovalRequired` inside the tool
10
+ node; the adapter catches it escaping `astream` → journaled
11
+ TOOL_APPROVAL_REQUIRED as the final frame (turn suspended, no sentinel).
12
+ - resume: rebuild messages from the transcript, execute the approved tool
13
+ via the sectr tool wrapper (or denial), append the ToolMessage, continue.
14
+
15
+ Post-MVP option (recorded in sdk-python/PLAN.md): a session-scoped
16
+ checkpointer workspace enables idiomatic `interrupt()` /
17
+ `Command(resume=decision)` tool bodies. Implementation pending.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import AsyncIterator
23
+ from typing import Any, cast
24
+
25
+ from ..app import Handler
26
+ from ..context import SessionContext
27
+ from ..events import RunnerEvent
28
+ from ..tools import ApprovalRequired
29
+
30
+ __all__ = ["langgraph", "as_langgraph_tool"]
31
+
32
+
33
+ def langgraph(graph: Any) -> Handler:
34
+ """Handler factory wrapping a compiled LangGraph. Implementation pending."""
35
+
36
+ async def handler(ctx: SessionContext) -> AsyncIterator[RunnerEvent]:
37
+ raise NotImplementedError("sectr.adapters.langgraph")
38
+ yield # pragma: no cover - makes this an async generator
39
+
40
+ return handler
41
+
42
+
43
+ def as_langgraph_tool(t: Any) -> Any:
44
+ """Convert a sectr `Tool` into a LangGraph/langchain-compatible tool,
45
+ preserving approval gating: the coroutine raises `ApprovalRequired`
46
+ inside the tool node when gated (the adapter catches it escaping
47
+ `astream` and ends the turn). Skeleton implementation — schema inferred
48
+ from the original function signature; golden tests arrive with the
49
+ adapter's functional round."""
50
+ import inspect
51
+
52
+ from langchain_core.tools import StructuredTool
53
+ from pydantic import create_model
54
+
55
+ if t.description is None:
56
+ raise ValueError(f"tool {t.name!r} needs a docstring (used as the LLM description)")
57
+
58
+ params = inspect.signature(t.fn).parameters
59
+ fields = {
60
+ pname: (
61
+ p.annotation if p.annotation is not inspect.Parameter.empty else str,
62
+ ... if p.default is inspect.Parameter.empty else p.default,
63
+ )
64
+ for pname, p in params.items()
65
+ }
66
+ # pydantic's create_model overloads don't cleanly accept a heterogeneous
67
+ # kwargs dict — a cast is honest here; schema correctness is golden-tested
68
+ # with the adapter's functional round.
69
+ args_schema = create_model(f"{t.name}_args", **cast(Any, fields))
70
+
71
+ from ..tools import is_gated
72
+
73
+ async def _run(**kwargs: Any) -> Any:
74
+ if is_gated(t.name):
75
+ raise ApprovalRequired(t.name, kwargs)
76
+ return await t(**kwargs)
77
+
78
+ return StructuredTool.from_function(
79
+ coroutine=_run, name=t.name, description=t.description, args_schema=args_schema
80
+ )
@@ -0,0 +1,399 @@
1
+ """openai-agents adapter (`sectr[openai-agents]` extra).
2
+
3
+ `openai_agents(agent, *, model=None, run_config=None)` returns a handler
4
+ mapping the SDK's native streaming + HITL onto the platform contract
5
+ (design: `sdk-python/PLAN.md`, "Resume semantics (framework adapters)"):
6
+
7
+ - normal turn: transcript → input items; `Runner.run_streamed`; text deltas
8
+ → MESSAGE_DELTA; `tool_called` → TOOL_CALL; `tool_output` → TOOL_RESULT;
9
+ exhaustion → NO_MORE_ACTIONS (appended by `drive`).
10
+ - pause: the framework's native interruption path — approval-gated tools
11
+ (`needs_approval` ∪ platform `SECTR_APPROVAL_POLICY`) produce
12
+ `result.interruptions` after the stream drains. Verified against SDK 0.22:
13
+ the gated call DOES stream a `tool_called` item before the interruption
14
+ (only the `ToolApprovalItem` itself is suppressed from the item stream),
15
+ so TOOL_CALL is journaled from the stream; the interruption path journals
16
+ it too only if the stream never emitted it (defensive against SDK
17
+ changes), then TOOL_APPROVAL_REQUIRED as the final frame (turn suspended).
18
+ More than one interruption in one turn cannot be represented by the
19
+ contract (one decision per resume) and fails loudly — batched approvals
20
+ are backlogged (see schema/PLAN.md).
21
+ - resume (`reason.kind == "approval_decision"`): NO RunState — the runner is
22
+ stateless and the journal is framework-opaque. Input items are rebuilt
23
+ from the transcript, the pending call is answered (approved: the tool
24
+ object is executed directly; denied: the SDK's model-visible rejection
25
+ text), and the run continues — the LLM sees the full conversation
26
+ including the answered call and generates only the continuation.
27
+ - tracing is disabled by default (dev runs have no OpenAI tracing sink);
28
+ pass your own `run_config` to override any of this.
29
+
30
+ The sectr tool registry (`sectr.tools`) is NOT involved — openai-agents has
31
+ native HITL, so tools stay 100% framework code (zero sectr imports in the
32
+ user's tool module, per `examples/PLAN.md`).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import uuid
39
+ from collections.abc import AsyncIterator
40
+ from dataclasses import replace
41
+ from typing import Any, cast
42
+
43
+ from agents import Agent as OAgent # noqa: N811 — disambiguate from sectr
44
+ from agents import Model, RunConfig, Runner
45
+ from agents.tool_context import ToolContext
46
+ from agents.items import (
47
+ MessageOutputItem,
48
+ ToolApprovalItem,
49
+ ToolCallItem,
50
+ ToolCallOutputItem,
51
+ )
52
+ from agents.tool import FunctionTool
53
+ from agents.stream_events import RawResponsesStreamEvent, RunItemStreamEvent
54
+
55
+ from ..app import Handler
56
+ from ..context import SessionContext
57
+ from ..events import (
58
+ Error,
59
+ MessageDelta,
60
+ MessageEnd,
61
+ MessageStart,
62
+ RunnerEvent,
63
+ ToolCall,
64
+ ToolApprovalRequired,
65
+ ToolResult,
66
+ )
67
+ from ..tools import platform_approval_policy
68
+
69
+ __all__ = ["openai_agents"]
70
+
71
+ # The SDK's default model-visible text for a rejected approval
72
+ # (`agents.run_internal.items.REJECTION_MESSAGE`); kept literal so the
73
+ # adapter doesn't depend on a private module path.
74
+ REJECTION_MESSAGE = "Tool execution was not approved."
75
+
76
+
77
+ def openai_agents(
78
+ agent: OAgent,
79
+ *,
80
+ model: Model | None = None,
81
+ run_config: RunConfig | None = None,
82
+ ) -> Handler:
83
+ """Handler factory wrapping an `agents.Agent`.
84
+
85
+ `model` / `run_config` pass through to `Runner.run_streamed` — used for
86
+ deterministic fakes in CI (`SECTR_FAKE=1` in the examples) and power-user
87
+ config. Tracing is disabled unless the caller supplies a run_config.
88
+ """
89
+
90
+ async def handler(ctx: SessionContext) -> AsyncIterator[RunnerEvent]:
91
+ run_agent = _apply_platform_policy(agent)
92
+ config = run_config or RunConfig(model=model, tracing_disabled=True)
93
+
94
+ input_items: Any
95
+ resumed_result: ToolResult | None = None
96
+ if ctx.reason.get("kind") == "approval_decision":
97
+ input_items, resumed_result = await _resume_input(ctx, agent)
98
+ else:
99
+ # EVERY turn continues the conversation: rebuild input items
100
+ # from the canonical transcript (which already contains this
101
+ # invocation's user message via its INVOCATION_STARTED).
102
+ # Feeding only `ctx.input` here ran every user turn as a COLD
103
+ # conversation — the model never saw prior turns (found live
104
+ # in the M1 run: turn 2 forgot the order ID turn 1 had just
105
+ # looked up; scripted fake-model tests used self-contained
106
+ # turns and empty transcripts, so it was invisible).
107
+ input_items = _transcript_items(ctx)
108
+ if resumed_result is not None:
109
+ yield resumed_result
110
+
111
+ result = Runner.run_streamed(run_agent, cast(Any, input_items), run_config=config)
112
+
113
+ open_message: str | None = None # synthesized id of the streaming message
114
+ journaled_calls: set[str] = set() # call_ids already TOOL_CALL-journaled
115
+ async for event in result.stream_events():
116
+ if isinstance(event, RawResponsesStreamEvent):
117
+ raw = event.data # an OpenAI response stream event (0.22: `data`)
118
+ if raw.type == "response.output_text.delta":
119
+ if open_message is None:
120
+ open_message = uuid.uuid4().hex[:16]
121
+ yield MessageStart(role="assistant", message_id=open_message)
122
+ yield MessageDelta(
123
+ message_id=open_message,
124
+ delta={"type": "text", "text": raw.delta},
125
+ )
126
+ elif raw.type in (
127
+ "response.reasoning_summary_text.delta",
128
+ "response.reasoning_text.delta",
129
+ ):
130
+ if open_message is None:
131
+ open_message = uuid.uuid4().hex[:16]
132
+ yield MessageStart(role="assistant", message_id=open_message)
133
+ yield MessageDelta(
134
+ message_id=open_message,
135
+ delta={"type": "reasoning", "text": raw.delta},
136
+ )
137
+ continue
138
+ if isinstance(event, RunItemStreamEvent):
139
+ if isinstance(event.item, MessageOutputItem):
140
+ if open_message is not None:
141
+ yield MessageEnd(message_id=open_message)
142
+ open_message = None
143
+ elif isinstance(event.item, ToolCallItem):
144
+ call: Any = event.item.raw_item
145
+ call_id = _call_id(call)
146
+ journaled_calls.add(call_id)
147
+ yield ToolCall(
148
+ tool_call_id=call_id,
149
+ name=_name(call),
150
+ arguments=_loads(_arguments(call)),
151
+ )
152
+ elif isinstance(event.item, ToolCallOutputItem):
153
+ yield ToolResult(
154
+ tool_call_id=_call_id(event.item.raw_item),
155
+ result=event.item.output,
156
+ )
157
+
158
+ if not result.interruptions:
159
+ return # drive appends NO_MORE_ACTIONS
160
+
161
+ if len(result.interruptions) > 1:
162
+ # The contract suspends a turn on exactly one decision; parallel
163
+ # gated calls can't be mapped honestly yet (batched approvals are
164
+ # backlogged — schema/PLAN.md).
165
+ yield Error(
166
+ message=(
167
+ f"{len(result.interruptions)} tool calls need approval in one "
168
+ "turn; batched approvals are not supported yet"
169
+ ),
170
+ code="multiple_approvals",
171
+ )
172
+ return
173
+
174
+ interruption: ToolApprovalItem = result.interruptions[0]
175
+ call = interruption.raw_item # typed `Any` at the ToolCallItem branch above
176
+ call_id, name = _call_id(call), _name(call)
177
+ if call_id not in journaled_calls:
178
+ # Gated calls are normally journaled via the streamed tool_called
179
+ # item; journal here too in case an SDK change stops emitting it —
180
+ # name + arguments must suffice for a resumed runner (contract).
181
+ yield ToolCall(tool_call_id=call_id, name=name, arguments=_loads(_arguments(call)))
182
+ yield ToolApprovalRequired(
183
+ tool_call_id=call_id,
184
+ name=name,
185
+ arguments=_loads(_arguments(call)),
186
+ )
187
+
188
+ # Framework marker — `sectr inspect` surfaces this in the manifest and
189
+ # the dev gateway reports it via /dev/status (DevStatus.framework).
190
+ handler.sectr_framework = "openai-agents" # type: ignore[attr-defined]
191
+ return handler
192
+
193
+
194
+ # ----------------------------------------------------------------------
195
+ # Platform policy union
196
+ # ----------------------------------------------------------------------
197
+
198
+
199
+ def _apply_platform_policy(agent: OAgent) -> OAgent:
200
+ """Mark tools named by `SECTR_APPROVAL_POLICY` as approval-gated. The
201
+ framework then treats them exactly like code-marked `needs_approval`
202
+ tools — deployment config alone can gate a tool the user forgot to
203
+ mark. No-op when the policy is empty or already covers every name."""
204
+ policy = set(platform_approval_policy())
205
+ if not policy:
206
+ return agent
207
+ marked = {
208
+ tool.name: replace(tool, needs_approval=True) # type: ignore[arg-type]
209
+ for tool in agent.tools
210
+ if tool.name in policy
211
+ }
212
+ if not marked:
213
+ return agent
214
+ tools = [marked.get(tool.name, tool) for tool in agent.tools]
215
+ return agent.clone(tools=tools)
216
+
217
+
218
+ # ----------------------------------------------------------------------
219
+ # Resume: transcript → input items
220
+ # ----------------------------------------------------------------------
221
+
222
+
223
+ from openai.types.responses import ResponseInputItemParam
224
+
225
+
226
+ def _transcript_items(ctx: SessionContext) -> list[ResponseInputItemParam]:
227
+ """Convert the canonical transcript view (`ctx.messages`) into
228
+ openai-agents input items. Used by BOTH turn kinds — a user_message
229
+ turn continues the conversation (the transcript's last message is the
230
+ new user input); the approval-resume path appends the answered pending
231
+ call on top.
232
+
233
+ - user/assistant easy messages pass through as-is
234
+ - journaled TOOL_CALLs (chat-style `tool_calls`) become `function_call`
235
+ items with their original call_ids
236
+ - journaled TOOL_RESULTs become `function_call_output` items
237
+
238
+ The return type is the SDK's item union (Literal-tagged TypedDicts) so
239
+ mypy rejects unknown item type names / malformed shapes statically —
240
+ `function_call_result` (the wrong vocabulary, three live failures ago)
241
+ fails to typecheck here.
242
+ """
243
+ items: list[ResponseInputItemParam] = []
244
+ for msg in ctx.messages:
245
+ if msg["role"] == "user":
246
+ # `input` is opaque JSON per the invoke contract ("exactly as
247
+ # submitted"): the CLI sends a string, the console sends
248
+ # {"message": …}. Provider items need string content — a dict
249
+ # here hits the converter's part-iterator and explodes with
250
+ # `Unknown content: message` (it iterates the dict's KEYS).
251
+ # Stringify exactly the way the old current-input path did.
252
+ content = msg["content"]
253
+ items.append(
254
+ {"role": "user", "content": content if isinstance(content, str) else _dumps(content)}
255
+ )
256
+ elif msg["role"] == "tool":
257
+ items.append(
258
+ {
259
+ "type": "function_call_output",
260
+ "call_id": msg["tool_call_id"],
261
+ "output": msg["content"],
262
+ }
263
+ )
264
+ else: # assistant
265
+ if msg.get("content"):
266
+ items.append({"role": "assistant", "content": msg["content"]})
267
+ for call in msg.get("tool_calls") or []:
268
+ items.append(
269
+ {
270
+ "type": "function_call",
271
+ "call_id": call["id"],
272
+ "name": call["function"]["name"],
273
+ "arguments": call["function"]["arguments"],
274
+ }
275
+ )
276
+
277
+ # The invoke contract guarantees the transcript contains this
278
+ # invocation's INVOCATION_STARTED, so the fold above already emitted
279
+ # the current user message. A partial transcript (tests, harnesses)
280
+ # falls back to appending the current input — a turn never runs
281
+ # without its user message.
282
+ if ctx.reason.get("kind") == "user_message" and ctx.reason.get("input") is not None:
283
+ raw = ctx.reason["input"]
284
+ if not any(m["role"] == "user" and m["content"] == raw for m in ctx.messages):
285
+ items.append(
286
+ {"role": "user", "content": raw if isinstance(raw, str) else _dumps(raw)}
287
+ )
288
+ return items
289
+
290
+
291
+ async def _resume_input(
292
+ ctx: SessionContext, agent: OAgent
293
+ ) -> tuple[list[ResponseInputItemParam], ToolResult | None]:
294
+ """Rebuild openai-agents input items from the canonical transcript view
295
+ (`ctx.messages`) via [`_transcript_items`], then answer the pending
296
+ approval call. The answered tool's result is also journaled (the
297
+ returned ToolResult event — the run itself will never re-emit it, the
298
+ answered call is already in the conversation).
299
+ """
300
+ items = _transcript_items(ctx)
301
+
302
+ pending = ctx.pending_tool_call
303
+ if pending is None:
304
+ return items, None
305
+
306
+ # The journaled TOOL_CALL already produced a function_call item above;
307
+ # only append it when absent (defensive for journals without one).
308
+ if not any(
309
+ item.get("type") == "function_call" and item.get("call_id") == pending["tool_call_id"]
310
+ for item in items
311
+ ):
312
+ items.append(
313
+ {
314
+ "type": "function_call",
315
+ "call_id": pending["tool_call_id"],
316
+ "name": pending["name"],
317
+ "arguments": _dumps(pending["arguments"]),
318
+ }
319
+ )
320
+
321
+ if ctx.decision["approved"]:
322
+ output = await _execute_pending(agent, pending)
323
+ else:
324
+ output = REJECTION_MESSAGE
325
+ items.append(
326
+ {"type": "function_call_output", "call_id": pending["tool_call_id"], "output": output}
327
+ )
328
+ return items, ToolResult(tool_call_id=pending["tool_call_id"], result=output)
329
+
330
+
331
+ async def _execute_pending(agent: OAgent, pending: dict[str, Any]) -> Any:
332
+ """Execute the approved tool call directly (`FunctionTool.on_invoke_tool`).
333
+
334
+ The decision is in hand, so gating does not apply — the same rule as
335
+ raw-mode `ctx.execute` on an approval-resume turn. The tool object comes
336
+ from the agent's tool list by name (name + arguments are in the journal;
337
+ the original process is gone).
338
+ """
339
+ tool = next(
340
+ (t for t in agent.tools if isinstance(t, FunctionTool) and t.name == pending["name"]),
341
+ None,
342
+ )
343
+ if tool is None:
344
+ raise RuntimeError(f"approved tool {pending['name']!r} is not on the agent")
345
+ arguments = pending["arguments"]
346
+ if not isinstance(arguments, str):
347
+ arguments = _dumps(arguments)
348
+ tool_context = ToolContext(
349
+ context=None,
350
+ tool_name=pending["name"],
351
+ tool_call_id=pending["tool_call_id"],
352
+ tool_arguments=arguments,
353
+ agent=agent,
354
+ run_config=RunConfig(),
355
+ )
356
+ return await tool.on_invoke_tool(tool_context, arguments)
357
+
358
+
359
+ # ----------------------------------------------------------------------
360
+ # helpers
361
+ # ----------------------------------------------------------------------
362
+
363
+
364
+ def _call_id(raw: Any) -> str:
365
+ """Run-item raw payloads are pydantic objects or plain param dicts
366
+ depending on path — read defensively (the union is wide; only function
367
+ calls reach these attributes in practice)."""
368
+ if isinstance(raw, dict):
369
+ return raw.get("call_id") or ""
370
+ return getattr(raw, "call_id", "") or ""
371
+
372
+
373
+ def _name(raw: Any) -> str:
374
+ if isinstance(raw, dict):
375
+ return raw.get("name") or ""
376
+ return getattr(raw, "name", "") or ""
377
+
378
+
379
+ def _arguments(raw: Any) -> Any:
380
+ if isinstance(raw, dict):
381
+ return raw.get("arguments")
382
+ return getattr(raw, "arguments", None)
383
+
384
+
385
+ def _loads(value: Any) -> Any:
386
+ """Model-emitted arguments arrive as a JSON string; parse so the journal
387
+ stores structured data (the schema wants `arguments: any`)."""
388
+ if isinstance(value, str):
389
+ try:
390
+ return json.loads(value)
391
+ except json.JSONDecodeError:
392
+ return value
393
+ return value
394
+
395
+
396
+ def _dumps(value: Any) -> str:
397
+ if isinstance(value, str):
398
+ return value
399
+ return json.dumps(value, default=str)
sectr/app.py ADDED
@@ -0,0 +1,79 @@
1
+ """AgentApp — route registry and manifest source (see `sdk-python/PLAN.md`).
2
+
3
+ Handlers all share one signature — `(SessionContext) -> AsyncIterator[
4
+ RunnerEvent]` — whether hand-written (`@app.agent_route`) or produced by a
5
+ framework adapter (`app.add_route("/chat", openai_agents(agent))`). The
6
+ FastAPI adapter (`sectr.server`) mounts the registry as `POST /invocations`
7
+ endpoints dispatched by `route`; `sectr.inspect` extracts the manifest.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import AsyncIterator, Callable
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from .context import SessionContext
18
+ from .events import RunnerEvent
19
+
20
+ __all__ = ["AgentApp", "Route", "Handler"]
21
+
22
+ Handler = Callable[[SessionContext], AsyncIterator[RunnerEvent]]
23
+
24
+ _ROUTE_RE = re.compile(r"^/[A-Za-z0-9_\-/]*$")
25
+
26
+
27
+ @dataclass
28
+ class Route:
29
+ path: str
30
+ handler: Handler
31
+
32
+
33
+ class AgentApp:
34
+ """Ordered route registry. One per user project; discovered by
35
+ `sectr inspect` via the `entry = "module:attribute"` in sectr.toml."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ name: str | None = None,
41
+ env: list[str] | None = None,
42
+ ) -> None:
43
+ self.name = name
44
+ self.routes: list[Route] = []
45
+ # Env var NAMES the sidecar must inject from the secret store at
46
+ # spawn. Values never pass through the manifest (write-only secrets).
47
+ self.env: tuple[str, ...] = tuple(env or ())
48
+
49
+ def add_route(self, path: str, handler: Handler) -> None:
50
+ """Register `handler` at `path` (e.g. "/customer-support"). Paths
51
+ must start with "/" and be unique — the platform routes sessions by
52
+ (app, path)."""
53
+ if not _ROUTE_RE.match(path):
54
+ raise ValueError(f"invalid route path {path!r} — must start with '/'")
55
+ if any(r.path == path for r in self.routes):
56
+ raise ValueError(f"route {path!r} already registered")
57
+ self.routes.append(Route(path=path, handler=handler))
58
+
59
+ def agent_route(self, path: str) -> Callable[[Handler], Handler]:
60
+ """Decorator flavor for hand-written handlers:
61
+
62
+ @app.agent_route("/chat")
63
+ async def chat(ctx: SessionContext): ...
64
+ """
65
+
66
+ def register(handler: Handler) -> Handler:
67
+ self.add_route(path, handler)
68
+ return handler
69
+
70
+ return register
71
+
72
+ def manifest(self) -> dict[str, Any]:
73
+ """The quick manifest (`sectr inspect` scan mode). The AUTHORITATIVE
74
+ manifest is extracted by importing this app under SECTR_MANIFEST=1 —
75
+ see `sectr.inspect`."""
76
+ return {
77
+ "name": self.name,
78
+ "routes": [{"path": r.path} for r in self.routes],
79
+ }