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.
@@ -0,0 +1,2 @@
1
+ OPENROUTER_API_KEY=sk-or-v1-replace-me
2
+ OPENROUTER_MODEL=openai/gpt-4o-mini
@@ -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
@@ -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."""