semora-ui 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- semora_ui-0.1.0/.env.example +2 -0
- semora_ui-0.1.0/.gitignore +28 -0
- semora_ui-0.1.0/PKG-INFO +22 -0
- semora_ui-0.1.0/README.md +71 -0
- semora_ui-0.1.0/pyproject.toml +39 -0
- semora_ui-0.1.0/src/semora_ui/__init__.py +1 -0
- semora_ui-0.1.0/src/semora_ui/api.py +253 -0
- semora_ui-0.1.0/src/semora_ui/app.py +42 -0
- semora_ui-0.1.0/src/semora_ui/children.py +144 -0
- semora_ui-0.1.0/src/semora_ui/config.py +63 -0
- semora_ui-0.1.0/src/semora_ui/execution.py +143 -0
- semora_ui-0.1.0/src/semora_ui/policy.py +23 -0
- semora_ui-0.1.0/src/semora_ui/provider.py +89 -0
- semora_ui-0.1.0/src/semora_ui/py.typed +0 -0
- semora_ui-0.1.0/src/semora_ui/recording.py +121 -0
- semora_ui-0.1.0/src/semora_ui/schemas.py +41 -0
- semora_ui-0.1.0/src/semora_ui/state.py +103 -0
- semora_ui-0.1.0/src/semora_ui/static/app.js +588 -0
- semora_ui-0.1.0/src/semora_ui/static/index.html +109 -0
- semora_ui-0.1.0/src/semora_ui/static/styles.css +146 -0
- semora_ui-0.1.0/src/semora_ui/tools.py +98 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.pytest_cache/
|
|
3
|
+
.mypy_cache/
|
|
4
|
+
.ruff_cache/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Local tool/editor state — machine-specific, never pushed.
|
|
18
|
+
.claude/
|
|
19
|
+
.codecanvas/
|
|
20
|
+
.vscode/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Superpowers design/spec scratch — working notes, not project documentation.
|
|
24
|
+
docs/superpowers/
|
|
25
|
+
|
|
26
|
+
# 로컬 자격증명 — 절대 커밋 금지.
|
|
27
|
+
a.txt
|
|
28
|
+
*.token
|
semora_ui-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: semora-ui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local console for driving Semora runs: chat, tool effects, suspension and resume.
|
|
5
|
+
Project-URL: Homepage, https://github.com/donggyun112/semora
|
|
6
|
+
Project-URL: Source, https://github.com/donggyun112/semora
|
|
7
|
+
Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
|
|
8
|
+
Author: donggyun112
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: fastapi<1,>=0.116
|
|
17
|
+
Requires-Dist: langchain-openai<2,>=0.3
|
|
18
|
+
Requires-Dist: loguru<1,>=0.7
|
|
19
|
+
Requires-Dist: python-dotenv<2,>=1.1
|
|
20
|
+
Requires-Dist: semora-store==0.1.0
|
|
21
|
+
Requires-Dist: semora==0.1.0
|
|
22
|
+
Requires-Dist: uvicorn[standard]<1,>=0.35
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Semora Durable Agent Lab
|
|
2
|
+
|
|
3
|
+
The local UI is an optional module inside the Semora package. Its dependency direction is:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
app → api → execution → AgentRuntime
|
|
7
|
+
↘ provider
|
|
8
|
+
↘ tools / state
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run it with:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv sync --extra ui
|
|
15
|
+
uv run uvicorn semora_ui.app:app --reload --port 8790
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Open <http://127.0.0.1:8790>. Configuration is loaded from `packages/semora-ui/.env`. Supported key
|
|
19
|
+
names are `OPENROUTER_API_KEY`, `OPENROUTER_KEY`, and the imported fixture spelling
|
|
20
|
+
`OPEN_ROTURE`.
|
|
21
|
+
|
|
22
|
+
The **Pre-tool gate** sample asks permission before `remember_note` crosses the effect boundary. No
|
|
23
|
+
`post_tool_use` event exists until approval actually executes the tool.
|
|
24
|
+
Tools cannot return a suspension after execution; `suspend` is reserved for `pre_tool_use` policy.
|
|
25
|
+
|
|
26
|
+
The **Tool failure** sample calls `simulate_api_failure`, which returns a recorded, non-retryable
|
|
27
|
+
503 result. The UI must show `post_tool_use_failure` and a failed Tool Result; neither the agent
|
|
28
|
+
loop nor the demo tool retries the call automatically.
|
|
29
|
+
|
|
30
|
+
The **Step recovery** sample arms a worker-only fault immediately after a tool result is committed
|
|
31
|
+
to `MemorySteps`, but before the ToolMessage reaches the agent transcript. The UI host remains
|
|
32
|
+
alive, and **Recover from committed Step** calls `AgentRuntime.recover`: the ledger result is
|
|
33
|
+
reused and the demo tool must execute exactly once (`execution_count: 1` remains visible in the
|
|
34
|
+
recovered result). This simulates an execution worker crash, not a full UI server restart; the
|
|
35
|
+
latter requires a persistent StepLog and transcript store.
|
|
36
|
+
|
|
37
|
+
The UI labels the model output **TOOL REQUEST**, not tool execution. If a new chat message arrives
|
|
38
|
+
while the sample is waiting, the interactive default cancels the unanswered request, writes its
|
|
39
|
+
protocol-closing ToolMessage, and continues the same run with the new message. Approval responses
|
|
40
|
+
use `pending_id`; a late approval for the cancelled request is rejected.
|
|
41
|
+
|
|
42
|
+
## Subagents
|
|
43
|
+
|
|
44
|
+
The `SUBAGENTS` row of sample prompts drives every delegation shape the runtime has, against three
|
|
45
|
+
demo children — `note-keeper`, `echoer`, and `flaky`. They are real runs, not canned strings: each
|
|
46
|
+
one goes through `AgentRuntime` on the run id `Subagents` derived for it, on the same ledger the
|
|
47
|
+
parent uses, with `respond_to_parent` in reach.
|
|
48
|
+
|
|
49
|
+
| Button | What it shows |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Sync | The parent's round waits. The child's own stream renders beside it, then its answer. |
|
|
52
|
+
| Handoff | The parent answers immediately; the child appears on the subagent rail as `running` and its answer arrives on a later round. |
|
|
53
|
+
| Fan-out | Two children in one call, both answers in one result. |
|
|
54
|
+
| Open independent | `wait="none"` — no leash, just a run id, listed under INDEPENDENT with a **Talk to it** button. |
|
|
55
|
+
| check_tasks | What the model itself sees of the children it launched. |
|
|
56
|
+
| Child failure | A child reporting a failure, distinct from a child that crashed. |
|
|
57
|
+
|
|
58
|
+
The rail keeps the two relationships apart because they are different: what is `ON THE PARENT'S
|
|
59
|
+
LEASH` can be cancelled from here and by the model's `cancel_task`; what is `INDEPENDENT` has an
|
|
60
|
+
address and nothing else, which is the entire result of opening one.
|
|
61
|
+
|
|
62
|
+
**Talk to it** spends that address — the same durable queue a steer crosses, aimed at the child's
|
|
63
|
+
run — and the agent remembers its earlier turns. This console still uses `recording.py` around an
|
|
64
|
+
in-memory transcript so its UI can render each observed message; core callers can instead inject
|
|
65
|
+
that store directly through `AgentRuntime(transcript=...)`. The console's store ends where this
|
|
66
|
+
process does, and `GET /api/transcript/{run_id}` shows exactly what was kept.
|
|
67
|
+
|
|
68
|
+
One thing to know when testing it: the demo system prompt tells the agent to use a tool whenever
|
|
69
|
+
asked to *recall a note*, so "what did I tell you earlier?" makes it call `recall_note`, find
|
|
70
|
+
nothing, and answer that it does not remember — with the whole conversation sitting in its context.
|
|
71
|
+
Ask without that word and it answers from the transcript.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "semora-ui"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A local console for driving Semora runs: chat, tool effects, suspension and resume."
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "donggyun112" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
|
|
20
|
+
dependencies = [
|
|
21
|
+
"semora==0.1.0",
|
|
22
|
+
"semora-store==0.1.0",
|
|
23
|
+
"fastapi>=0.116,<1",
|
|
24
|
+
"loguru>=0.7,<1",
|
|
25
|
+
"langchain-openai>=0.3,<2",
|
|
26
|
+
"python-dotenv>=1.1,<2",
|
|
27
|
+
"uvicorn[standard]>=0.35,<1",
|
|
28
|
+
]
|
|
29
|
+
# A demo application, not a library. It is the one package allowed to depend on everything.
|
|
30
|
+
# `semora-store` is declared rather than taken transitively through `semora`: the console names
|
|
31
|
+
# `Transcript` itself, and an import that only works because something else pulled it in is the
|
|
32
|
+
# breakage `tests/test_packaging.py` exists to catch.
|
|
33
|
+
|
|
34
|
+
[tool.uv.sources]
|
|
35
|
+
semora = { workspace = true }
|
|
36
|
+
semora-store = { workspace = true }
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/semora_ui"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional local UI for observing Semora's durable execution path."""
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""FastAPI routes for the Semora test console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
from fastapi.responses import StreamingResponse
|
|
10
|
+
from semora import Agent, new_run_id
|
|
11
|
+
from semora.contracts import Tools
|
|
12
|
+
from semora.dispatch import Answer, Prompt, Recover
|
|
13
|
+
from semora.runtime import AgentRuntime
|
|
14
|
+
|
|
15
|
+
from .config import SETTINGS, SYSTEM_PROMPT
|
|
16
|
+
from .execution import AgentEvent, capped, stream_attempt
|
|
17
|
+
from .policy import permission_controls
|
|
18
|
+
from .provider import openrouter_model
|
|
19
|
+
from .recording import Recorder, history_of
|
|
20
|
+
from .schemas import AttachRequest, CancelRequest, RecoverRequest, ResumeRequest, RunRequest
|
|
21
|
+
from .state import STATE
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/api")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _console_agent(model_name: str, tools: Tools) -> Agent:
|
|
27
|
+
"""Bind the console's model selection and toolbox into one agent definition."""
|
|
28
|
+
return Agent(
|
|
29
|
+
name="console",
|
|
30
|
+
description="Semora test console agent",
|
|
31
|
+
model=openrouter_model(model_name),
|
|
32
|
+
tools=tools,
|
|
33
|
+
system_prompt=SYSTEM_PROMPT,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/health")
|
|
38
|
+
async def health() -> dict[str, Any]:
|
|
39
|
+
"""Return service health and provider configuration status."""
|
|
40
|
+
return {
|
|
41
|
+
"ok": True,
|
|
42
|
+
"openrouter_configured": SETTINGS.configured,
|
|
43
|
+
"default_model": SETTINGS.default_model,
|
|
44
|
+
"engine": "plain while + orchestrator",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/steps/{run_id}")
|
|
49
|
+
async def steps(run_id: str) -> dict[str, Any]:
|
|
50
|
+
"""Return the persisted step states for a run."""
|
|
51
|
+
return {"run_id": run_id, "steps": STATE.step_store.snapshot(run_id)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.post("/run")
|
|
55
|
+
async def run_agent(request: RunRequest) -> StreamingResponse:
|
|
56
|
+
"""Start an agent run and stream newline-delimited events."""
|
|
57
|
+
run_id = request.run_id or new_run_id("ui")
|
|
58
|
+
session = STATE.session(run_id)
|
|
59
|
+
session.controls = permission_controls() if request.permission_gate else None
|
|
60
|
+
prompt_id = f"{run_id}:prompt:{uuid4()}"
|
|
61
|
+
if request.fault_after_step_commit:
|
|
62
|
+
STATE.step_store.arm(run_id)
|
|
63
|
+
|
|
64
|
+
async def attempt(
|
|
65
|
+
runtime: AgentRuntime, tools: Tools, on_event: AgentEvent
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Dispatch the prompt through the shared runtime."""
|
|
68
|
+
try:
|
|
69
|
+
return await runtime.dispatch(
|
|
70
|
+
run_id,
|
|
71
|
+
_console_agent(request.model, tools),
|
|
72
|
+
Prompt(request.prompt, prompt_id=prompt_id),
|
|
73
|
+
controls=session.controls,
|
|
74
|
+
on_event=on_event,
|
|
75
|
+
should_stop_after_turn=capped,
|
|
76
|
+
)
|
|
77
|
+
finally:
|
|
78
|
+
STATE.step_store.disarm(run_id)
|
|
79
|
+
|
|
80
|
+
return StreamingResponse(
|
|
81
|
+
stream_attempt(run_id, attempt, model=request.model),
|
|
82
|
+
media_type="application/x-ndjson",
|
|
83
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.post("/recover")
|
|
88
|
+
async def recover_agent(request: RecoverRequest) -> StreamingResponse:
|
|
89
|
+
"""Recover a run after a simulated post-commit worker crash.
|
|
90
|
+
|
|
91
|
+
The crashed round's model turn replays from the step journal, so nothing is captured
|
|
92
|
+
host-side; a run with nothing to recover streams an ``InvalidTransition`` error frame.
|
|
93
|
+
"""
|
|
94
|
+
if request.run_id not in STATE.sessions:
|
|
95
|
+
raise HTTPException(status_code=404, detail="unknown run_id")
|
|
96
|
+
session = STATE.sessions[request.run_id]
|
|
97
|
+
|
|
98
|
+
async def attempt(
|
|
99
|
+
runtime: AgentRuntime, tools: Tools, on_event: AgentEvent
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Dispatch the recovery through the shared runtime."""
|
|
102
|
+
return await runtime.dispatch(
|
|
103
|
+
request.run_id,
|
|
104
|
+
_console_agent(request.model, tools),
|
|
105
|
+
Recover(),
|
|
106
|
+
controls=session.controls,
|
|
107
|
+
on_event=on_event,
|
|
108
|
+
should_stop_after_turn=capped,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return StreamingResponse(
|
|
112
|
+
stream_attempt(request.run_id, attempt, model=request.model),
|
|
113
|
+
media_type="application/x-ndjson",
|
|
114
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.post("/resume")
|
|
119
|
+
async def resume_agent(request: ResumeRequest) -> StreamingResponse:
|
|
120
|
+
"""Resume a suspended tool call with an operator decision."""
|
|
121
|
+
if request.run_id not in STATE.sessions:
|
|
122
|
+
raise HTTPException(status_code=404, detail="unknown run_id")
|
|
123
|
+
session = STATE.sessions[request.run_id]
|
|
124
|
+
answer = (
|
|
125
|
+
{"type": "text", "text": "approved by the human"}
|
|
126
|
+
if request.approved
|
|
127
|
+
else {"type": "error", "message": "denied by the human"}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def attempt(
|
|
131
|
+
runtime: AgentRuntime, tools: Tools, on_event: AgentEvent
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Dispatch the operator's answer through the shared runtime."""
|
|
134
|
+
return await runtime.dispatch(
|
|
135
|
+
request.run_id,
|
|
136
|
+
_console_agent(request.model, tools),
|
|
137
|
+
Answer(request.pending_id, answer),
|
|
138
|
+
controls=session.controls,
|
|
139
|
+
on_event=on_event,
|
|
140
|
+
should_stop_after_turn=capped,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return StreamingResponse(
|
|
144
|
+
stream_attempt(request.run_id, attempt, model=request.model),
|
|
145
|
+
media_type="application/x-ndjson",
|
|
146
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.get("/tasks/{run_id}")
|
|
151
|
+
async def tasks(run_id: str) -> dict[str, Any]:
|
|
152
|
+
"""Return managed tasks and independent children for an agent run.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
run_id: Parent agent run identifier.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Mapping containing background task records and independent child addresses.
|
|
159
|
+
"""
|
|
160
|
+
session = STATE.sessions.get(run_id)
|
|
161
|
+
if session is None:
|
|
162
|
+
return {"run_id": run_id, "background": [], "independent": []}
|
|
163
|
+
return {
|
|
164
|
+
"run_id": run_id,
|
|
165
|
+
"background": session.tasks.list(),
|
|
166
|
+
"independent": list(session.opened),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.post("/tasks/cancel")
|
|
171
|
+
async def cancel_task(request: CancelRequest) -> dict[str, Any]:
|
|
172
|
+
"""Cancel a managed background task.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
request: Parent run and task identifiers.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Cancelled task identifier and the updated task list.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
HTTPException: If the run is unknown or the task is not running.
|
|
182
|
+
"""
|
|
183
|
+
session = STATE.sessions.get(request.run_id)
|
|
184
|
+
if session is None:
|
|
185
|
+
raise HTTPException(status_code=404, detail="unknown run_id")
|
|
186
|
+
if not session.tasks.cancel(request.task_id):
|
|
187
|
+
raise HTTPException(status_code=409, detail="no running task with that id")
|
|
188
|
+
return {"cancelled": request.task_id, "background": session.tasks.list()}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.post("/attach")
|
|
192
|
+
async def attach_agent(request: AttachRequest) -> StreamingResponse:
|
|
193
|
+
"""Submit a prompt to an independent child run.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
request: Child run identifier, prompt, and model selection.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Newline-delimited event stream for the child attempt.
|
|
200
|
+
|
|
201
|
+
Note:
|
|
202
|
+
Transcript continuity is provided by the console's recorder and restored as explicit
|
|
203
|
+
runtime history.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
async def attempt(
|
|
207
|
+
runtime: AgentRuntime, tools: Tools, on_event: AgentEvent
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""Execute one attempt for the independent child run."""
|
|
210
|
+
already_said = await history_of(STATE.transcripts, request.run_id)
|
|
211
|
+
recorder = await Recorder.open(STATE.transcripts, request.run_id, request.prompt)
|
|
212
|
+
|
|
213
|
+
async def watched(event: dict[str, Any]) -> None:
|
|
214
|
+
await recorder.observe(event)
|
|
215
|
+
await on_event(event)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
return await runtime.run(
|
|
219
|
+
request.run_id,
|
|
220
|
+
openrouter_model(request.model),
|
|
221
|
+
tools,
|
|
222
|
+
request.prompt,
|
|
223
|
+
on_event=watched,
|
|
224
|
+
system_prompt=SYSTEM_PROMPT,
|
|
225
|
+
should_stop_after_turn=capped,
|
|
226
|
+
history=already_said,
|
|
227
|
+
)
|
|
228
|
+
finally:
|
|
229
|
+
await recorder.closed()
|
|
230
|
+
|
|
231
|
+
return StreamingResponse(
|
|
232
|
+
stream_attempt(request.run_id, attempt, model=request.model),
|
|
233
|
+
media_type="application/x-ndjson",
|
|
234
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.get("/transcript/{run_id}")
|
|
239
|
+
async def transcript(run_id: str) -> dict[str, Any]:
|
|
240
|
+
"""What the console recorded of this run's conversation.
|
|
241
|
+
|
|
242
|
+
The panel for the thing that has no panel. `history` is what an attach hands back, so an
|
|
243
|
+
operator debugging a reattached agent that answers as a stranger can see whether the problem
|
|
244
|
+
is the recording or the model.
|
|
245
|
+
"""
|
|
246
|
+
return {
|
|
247
|
+
"run_id": run_id,
|
|
248
|
+
"entries": len(await STATE.transcripts.read(run_id)),
|
|
249
|
+
"history": [
|
|
250
|
+
{"role": type(message).__name__, "content": str(message.content)[:400]}
|
|
251
|
+
for message in await history_of(STATE.transcripts, run_id)
|
|
252
|
+
],
|
|
253
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""ASGI composition root for the optional Semora test UI."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.responses import FileResponse, Response
|
|
5
|
+
from fastapi.staticfiles import StaticFiles
|
|
6
|
+
from starlette.middleware.base import RequestResponseEndpoint
|
|
7
|
+
|
|
8
|
+
from .api import router
|
|
9
|
+
from .config import SETTINGS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_app() -> FastAPI:
|
|
13
|
+
"""Create and configure the test console application."""
|
|
14
|
+
application = FastAPI(title="Semora Durable Agent Lab")
|
|
15
|
+
|
|
16
|
+
@application.middleware("http")
|
|
17
|
+
async def disable_ui_cache(
|
|
18
|
+
request: Request, call_next: RequestResponseEndpoint
|
|
19
|
+
) -> Response:
|
|
20
|
+
"""Disable browser caching for the UI shell and static assets."""
|
|
21
|
+
response = await call_next(request)
|
|
22
|
+
if request.url.path == "/" or request.url.path.startswith("/assets/"):
|
|
23
|
+
response.headers["Cache-Control"] = "no-store, max-age=0"
|
|
24
|
+
response.headers["Pragma"] = "no-cache"
|
|
25
|
+
return response
|
|
26
|
+
|
|
27
|
+
application.include_router(router)
|
|
28
|
+
application.mount(
|
|
29
|
+
"/assets",
|
|
30
|
+
StaticFiles(directory=SETTINGS.ui_root / "static"),
|
|
31
|
+
name="assets",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@application.get("/", include_in_schema=False)
|
|
35
|
+
async def index() -> FileResponse:
|
|
36
|
+
"""Serve the test console entry page."""
|
|
37
|
+
return FileResponse(SETTINGS.ui_root / "static" / "index.html")
|
|
38
|
+
|
|
39
|
+
return application
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
app = create_app()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Subagent roster and runner adapters for the local test console.
|
|
2
|
+
|
|
3
|
+
Each child uses ``AgentRuntime`` with the parent's step and transcript stores. The runner adapter
|
|
4
|
+
exposes runtime callbacks as the event stream required by ``Subagents``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from semora.runtime import AgentRuntime
|
|
14
|
+
from semora.subagents import Answering, Reply, RunnerAgent, Subagent
|
|
15
|
+
|
|
16
|
+
from .config import SYSTEM_PROMPT
|
|
17
|
+
from .provider import openrouter_model
|
|
18
|
+
from .recording import Recorder, history_of
|
|
19
|
+
from .tools import DemoTools
|
|
20
|
+
|
|
21
|
+
CHILD_PROMPT = (
|
|
22
|
+
f"{SYSTEM_PROMPT}\n\n"
|
|
23
|
+
"You are a subagent. Another agent delegated this task to you. Do the work with your tools, "
|
|
24
|
+
"then call `respond_to_parent` exactly once with what you found. That call is how your answer "
|
|
25
|
+
"reaches the agent waiting on it, and it ends your run."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def _capped(turn: int, _text: str, _calls: list[dict[str, Any]]) -> bool:
|
|
29
|
+
"""Stop a console child after four model turns."""
|
|
30
|
+
return turn >= 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ROSTER: tuple[tuple[str, str, str], ...] = (
|
|
34
|
+
(
|
|
35
|
+
"note-keeper",
|
|
36
|
+
"Stores and recalls notes. Give it a key and a value, or a key to look up.",
|
|
37
|
+
"Use `remember_note` to store and `recall_note` to look up.",
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
"echoer",
|
|
41
|
+
"Echoes text back through a durable tool effect. Useful for testing a round trip.",
|
|
42
|
+
"Put whatever you are given through the `echo` tool.",
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"flaky",
|
|
46
|
+
"Calls an API that always fails. Use it to watch a child report a failure.",
|
|
47
|
+
"Call `simulate_api_failure` once, then report the failure it returned. A task with no "
|
|
48
|
+
"detail still gets that call — you exist to fail, so never ask the delegator what to do.",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
"""For each console subagent: its name, what the parent reads, and what the child is told.
|
|
52
|
+
|
|
53
|
+
The third field is why `flaky` fails. Name and description reach the delegating model only; a
|
|
54
|
+
child that is told nothing about its own job answers whatever the roster promised with a question.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def subagents(model: str, store: Any, transcripts: Any) -> list[Subagent]:
|
|
59
|
+
"""Build compiled child definitions for the console roster."""
|
|
60
|
+
return [
|
|
61
|
+
RunnerAgent(name, description, _runner(model, store, transcripts, instruction))
|
|
62
|
+
for name, description, instruction in ROSTER
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _runner(model: str, store: Any, transcripts: Any, instruction: str) -> Any:
|
|
67
|
+
"""Create a runner backed by durable step and transcript stores."""
|
|
68
|
+
|
|
69
|
+
async def run(prompt: str, reply: Reply, run_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
70
|
+
async for event in _stream_run(
|
|
71
|
+
AgentRuntime(store=store),
|
|
72
|
+
run_id,
|
|
73
|
+
model,
|
|
74
|
+
Answering(DemoTools(), reply),
|
|
75
|
+
prompt,
|
|
76
|
+
transcripts,
|
|
77
|
+
instruction,
|
|
78
|
+
):
|
|
79
|
+
yield event
|
|
80
|
+
|
|
81
|
+
return run
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _stream_run(
|
|
85
|
+
runtime: AgentRuntime,
|
|
86
|
+
run_id: str,
|
|
87
|
+
model: str,
|
|
88
|
+
tools: Any,
|
|
89
|
+
prompt: str,
|
|
90
|
+
transcripts: Any,
|
|
91
|
+
instruction: str,
|
|
92
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
93
|
+
"""Run a child and yield its planner events in publication order."""
|
|
94
|
+
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
|
|
95
|
+
already_said = await history_of(transcripts, run_id)
|
|
96
|
+
recorder = await Recorder.open(transcripts, run_id, prompt)
|
|
97
|
+
|
|
98
|
+
async def on_event(event: dict[str, Any]) -> None:
|
|
99
|
+
await recorder.observe(event)
|
|
100
|
+
await queue.put(event)
|
|
101
|
+
|
|
102
|
+
async def drive() -> dict[str, Any]:
|
|
103
|
+
try:
|
|
104
|
+
# Whatever this child already said. Empty on its first turn, and the reason a second
|
|
105
|
+
# turn on the same run id continues rather than starting a stranger.
|
|
106
|
+
return await runtime.run(
|
|
107
|
+
run_id,
|
|
108
|
+
openrouter_model(model),
|
|
109
|
+
tools,
|
|
110
|
+
prompt,
|
|
111
|
+
on_event=on_event,
|
|
112
|
+
system_prompt=f"{CHILD_PROMPT}\n\n{instruction}",
|
|
113
|
+
should_stop_after_turn=_capped,
|
|
114
|
+
history=already_said,
|
|
115
|
+
)
|
|
116
|
+
finally:
|
|
117
|
+
await recorder.closed()
|
|
118
|
+
await queue.put(None)
|
|
119
|
+
|
|
120
|
+
task = asyncio.create_task(drive())
|
|
121
|
+
terminal = False
|
|
122
|
+
try:
|
|
123
|
+
while (item := await queue.get()) is not None:
|
|
124
|
+
terminal = terminal or item.get("type") in {"done", "error"}
|
|
125
|
+
yield item
|
|
126
|
+
finally:
|
|
127
|
+
# Reached on a cancel too — `cancel_task` closes this generator, and a child left running
|
|
128
|
+
# after its leash was pulled is the thing that cancel exists to prevent.
|
|
129
|
+
if not task.done():
|
|
130
|
+
task.cancel()
|
|
131
|
+
try:
|
|
132
|
+
outcome = await task
|
|
133
|
+
except asyncio.CancelledError:
|
|
134
|
+
raise
|
|
135
|
+
except Exception as failure:
|
|
136
|
+
# The one thing the stream cannot carry: a run that raised instead of ending. `_drain`
|
|
137
|
+
# reads this as a failed child, so the parent is told rather than the exception crossing
|
|
138
|
+
# into its tool round.
|
|
139
|
+
yield {"type": "error", "message": f"{type(failure).__name__}: {failure}"}
|
|
140
|
+
return
|
|
141
|
+
# `on_event` already carried the terminal event, so re-yielding the outcome would show the
|
|
142
|
+
# child finishing twice. Yielded only when nothing terminal came through at all.
|
|
143
|
+
if not terminal:
|
|
144
|
+
yield outcome
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""UI configuration and secret loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
UI_ROOT = Path(__file__).resolve().parent
|
|
12
|
+
"""Directory containing the packaged UI assets."""
|
|
13
|
+
|
|
14
|
+
ENV_FILE = UI_ROOT.parents[1] / ".env"
|
|
15
|
+
"""Development environment file beside the UI package manifest."""
|
|
16
|
+
|
|
17
|
+
load_dotenv(ENV_FILE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Settings:
|
|
22
|
+
"""Store UI and OpenRouter configuration."""
|
|
23
|
+
ui_root: Path = UI_ROOT
|
|
24
|
+
default_model: str = "openai/gpt-4o-mini"
|
|
25
|
+
openrouter_api_key: str = field(default="", repr=False)
|
|
26
|
+
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
|
27
|
+
public_url: str = "http://127.0.0.1:8790"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def configured(self) -> bool:
|
|
31
|
+
"""Return whether an OpenRouter API key is configured."""
|
|
32
|
+
return bool(self.openrouter_api_key)
|
|
33
|
+
|
|
34
|
+
def require_api_key(self) -> str:
|
|
35
|
+
"""Return the configured API key or raise a configuration error."""
|
|
36
|
+
if not self.openrouter_api_key:
|
|
37
|
+
raise RuntimeError("OpenRouter API key is missing from packages/semora-ui/.env")
|
|
38
|
+
return self.openrouter_api_key
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_environment(cls) -> Settings:
|
|
42
|
+
"""Build settings from supported environment variables."""
|
|
43
|
+
# OPEN_ROTURE is the spelling used by the imported bug-case fixture.
|
|
44
|
+
key = (
|
|
45
|
+
os.getenv("OPENROUTER_API_KEY")
|
|
46
|
+
or os.getenv("OPENROUTER_KEY")
|
|
47
|
+
or os.getenv("OPEN_ROTURE")
|
|
48
|
+
or ""
|
|
49
|
+
)
|
|
50
|
+
return cls(
|
|
51
|
+
default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
|
52
|
+
openrouter_api_key=key,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SETTINGS = Settings.from_environment()
|
|
57
|
+
|
|
58
|
+
SYSTEM_PROMPT = """You are the Semora runtime test agent. Be concise.
|
|
59
|
+
Use a tool whenever the user explicitly asks you to echo text, remember or recall a note, inspect
|
|
60
|
+
the runtime clock, or simulate an API failure. The simulate_api_failure tool is a harmless test
|
|
61
|
+
fixture: call it immediately when explicitly requested and never ask the user for permission.
|
|
62
|
+
Never claim a tool ran when it did not. Permission approval happens before a tool executes and is
|
|
63
|
+
owned by the runtime, not by a tool result."""
|