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 +53 -0
- sectr/adapters/__init__.py +12 -0
- sectr/adapters/claude_code.py +45 -0
- sectr/adapters/langgraph.py +80 -0
- sectr/adapters/openai_agents.py +399 -0
- sectr/app.py +79 -0
- sectr/context.py +285 -0
- sectr/drive.py +86 -0
- sectr/events.py +177 -0
- sectr/inspect.py +143 -0
- sectr/py.typed +0 -0
- sectr/server.py +129 -0
- sectr/tools.py +199 -0
- sectr-0.0.4.dist-info/METADATA +18 -0
- sectr-0.0.4.dist-info/RECORD +16 -0
- sectr-0.0.4.dist-info/WHEEL +4 -0
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
|
+
}
|