deploy-bot commited on
Commit
48532a6
·
1 Parent(s): 0ee313e

Deploy from 40e5ccd

Browse files
app/harness/events.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process fan-out from a detached run to however many browsers are watching.
2
+
3
+ Deliberately not durable: the durable record is `skill_run_steps`, and the
4
+ stream endpoint replays from there before it follows this bus. That split is
5
+ what makes a disconnect survivable — the SSE path reads *from* the run record
6
+ rather than *being* the run.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ # Bounded so a browser that stops reading cannot grow memory without limit.
15
+ _MAX_QUEUE = 256
16
+
17
+ DONE = object()
18
+ """Sentinel pushed by close(): end of stream, not an event."""
19
+
20
+ _subscribers: dict[int, set[asyncio.Queue]] = {}
21
+
22
+
23
+ def subscribe(run_id: int) -> asyncio.Queue:
24
+ queue: asyncio.Queue = asyncio.Queue(maxsize=_MAX_QUEUE)
25
+ _subscribers.setdefault(run_id, set()).add(queue)
26
+ return queue
27
+
28
+
29
+ def unsubscribe(run_id: int, queue: asyncio.Queue) -> None:
30
+ listeners = _subscribers.get(run_id)
31
+ if listeners is None:
32
+ return
33
+ listeners.discard(queue)
34
+ if not listeners:
35
+ _subscribers.pop(run_id, None)
36
+
37
+
38
+ def publish(run_id: int, event: dict) -> None:
39
+ """Non-blocking by contract. A stalled reader loses events; it must never
40
+ stall the run producing them, and it never loses the durable step rows."""
41
+ for queue in list(_subscribers.get(run_id, ())):
42
+ try:
43
+ queue.put_nowait(event)
44
+ except asyncio.QueueFull:
45
+ log.warning("dropping event for run %s: subscriber queue full", run_id)
46
+
47
+
48
+ def close(run_id: int) -> None:
49
+ for queue in list(_subscribers.get(run_id, ())):
50
+ try:
51
+ queue.put_nowait(DONE)
52
+ except asyncio.QueueFull:
53
+ pass
54
+ _subscribers.pop(run_id, None)
app/harness/executor.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Starting a run and letting it outlive the request that asked for it.
2
+
3
+ `asyncio.create_task` on the same event loop — no worker process, no queue,
4
+ matching the single-instance Space. Closing the browser does not kill the run.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from app.harness import events, runner, runs, tools
13
+ from app.services import sync
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ # Strong references, keyed by run id. The event loop only holds a weak one, so
18
+ # a task not kept here can be garbage-collected mid-run; keying by run id is
19
+ # also what lets `cancel` find the task to stop.
20
+ _tasks: dict[int, asyncio.Task] = {}
21
+
22
+
23
+ class InvalidInput(Exception):
24
+ """The submitted input did not match the skill's declared input model."""
25
+
26
+
27
+ def build_registry() -> tools.ToolRegistry:
28
+ """The tools a run may call. PR 3 fills this in with the real builtins."""
29
+ return tools.ToolRegistry()
30
+
31
+
32
+ async def start(skill, inputs: dict, model: str | None, owner: bool) -> dict:
33
+ """Validate, create the run row, detach the work, return immediately.
34
+
35
+ Input validation happens BEFORE the row exists so a malformed submission
36
+ leaves no failed run cluttering history — and, more importantly, does not
37
+ consume the one-active-run slot.
38
+ """
39
+ try:
40
+ validated = skill.input_model.model_validate(inputs)
41
+ except ValidationError as exc:
42
+ raise InvalidInput(str(exc)) from exc
43
+
44
+ payload = validated.model_dump()
45
+ run = runs.create_run(skill.name, skill.scheduler.name, model, payload)
46
+ task = asyncio.create_task(execute(skill, run, payload, owner))
47
+ _tasks[run["id"]] = task
48
+ task.add_done_callback(lambda _t: _tasks.pop(run["id"], None))
49
+ return run
50
+
51
+
52
+ async def execute(skill, run: dict, inputs: dict, owner: bool) -> None:
53
+ """The detached body. Every exit path writes a terminal row — an exception
54
+ escaping here would vanish into the loop's handler and leave the row at
55
+ 'running' until the next boot's sweep."""
56
+ run_id = run["id"]
57
+ runs.start_run(run_id)
58
+ events.publish(run_id, {"type": "run-start", "run_id": run_id, "skill": skill.name})
59
+ ctx = runner.RunContext(
60
+ run_id=run_id, model=run["model"], owner=owner,
61
+ inputs=inputs, registry=build_registry(),
62
+ )
63
+ try:
64
+ output = await skill.scheduler.run(ctx)
65
+ runs.finish_run(run_id, output or {})
66
+ events.publish(
67
+ run_id, {"type": "run-done", "run_id": run_id, "output": output or {}}
68
+ )
69
+ except runner.StepFailure as exc:
70
+ runs.fail_run(run_id, exc.code, exc.message)
71
+ event = {"type": "error", "code": exc.code, "message": exc.message}
72
+ if exc.retry_after is not None:
73
+ event["retry_after"] = exc.retry_after
74
+ events.publish(run_id, event)
75
+ except asyncio.CancelledError:
76
+ # CancelledError is a BaseException, so the broad `except Exception`
77
+ # below would NOT catch it — without this the run row would be left at
78
+ # 'running' until the next boot's sweep. Re-raised after recording, so
79
+ # asyncio still sees a properly cancelled task.
80
+ runs.cancel_run(run_id)
81
+ events.publish(run_id, {"type": "error", "code": "cancelled",
82
+ "message": "The run was cancelled."})
83
+ raise
84
+ except Exception as exc:
85
+ log.exception("run %s failed unexpectedly", run_id)
86
+ runs.fail_run(run_id, "internal_error", str(exc))
87
+ events.publish(
88
+ run_id,
89
+ {"type": "error", "code": "internal_error", "message": "The run failed."},
90
+ )
91
+ finally:
92
+ events.close(run_id)
93
+ # Once, at the terminal state: the 30s debounce means pushing per-step
94
+ # would churn the dataset repo for no gain, but a finished run that is
95
+ # never pushed is lost to the next restart.
96
+ sync.schedule_push()
97
+
98
+
99
+ async def drain() -> None:
100
+ """Await every in-flight run. Test helper; also useful for a clean shutdown."""
101
+ while _tasks:
102
+ await asyncio.gather(*list(_tasks.values()), return_exceptions=True)
103
+
104
+
105
+ def cancel(run_id: int) -> bool:
106
+ """Stop an in-flight run. Returns False when there is nothing to stop.
107
+
108
+ The row is written by `execute`'s CancelledError handler rather than here,
109
+ so cancellation records itself through the same path whether it is
110
+ requested or arrives some other way.
111
+ """
112
+ task = _tasks.get(run_id)
113
+ if task is None or task.done():
114
+ return False
115
+ task.cancel()
116
+ return True
app/harness/runner.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The instrumented middle: what a step is, what a model call costs, and what
2
+ gets written down about both.
3
+
4
+ Every model call and every tool call goes through here, which is the only
5
+ reason the run record can be trusted as the source of truth for the live view
6
+ and for v0.8.0's analytics.
7
+ """
8
+
9
+ import json
10
+ import time
11
+ from contextlib import asynccontextmanager
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from app.config import get_settings
16
+ from app.harness import contracts, events, runs, tools
17
+ from app.services import openrouter
18
+
19
+
20
+ class StepFailure(Exception):
21
+ """A step could not produce valid output. Fails the run, recording which
22
+ step and why — no partial output is ever filed as a proposal."""
23
+
24
+ def __init__(self, code: str, message: str, retry_after: int | None = None):
25
+ super().__init__(message)
26
+ self.code = code
27
+ self.message = message
28
+ self.retry_after = retry_after
29
+
30
+
31
+ class BudgetExceeded(StepFailure):
32
+ def __init__(self, used: int, allowed: int):
33
+ super().__init__(
34
+ "budget_exceeded",
35
+ f"This run reached its {allowed}-call limit (used {used}).",
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class _OpenStep:
41
+ step_id: int
42
+ ordinal: int
43
+ started: float
44
+ model: str | None = None
45
+ tokens_in: int = 0
46
+ tokens_out: int = 0
47
+
48
+
49
+ @dataclass
50
+ class RunContext:
51
+ run_id: int
52
+ model: str | None
53
+ owner: bool
54
+ inputs: dict
55
+ registry: tools.ToolRegistry
56
+ max_calls: int | None = None
57
+ retries: int | None = None
58
+ state: dict = field(default_factory=dict)
59
+ calls_used: int = 0
60
+ _open: _OpenStep | None = None
61
+
62
+ def __post_init__(self) -> None:
63
+ settings = get_settings()
64
+ if self.max_calls is None:
65
+ self.max_calls = settings.skill_max_model_calls
66
+ if self.retries is None:
67
+ self.retries = settings.skill_contract_retries
68
+
69
+ def emit(self, event: dict) -> None:
70
+ events.publish(self.run_id, event)
71
+
72
+ @asynccontextmanager
73
+ async def step(self, name: str):
74
+ """Open a step: one row, one start event, one done/failed event.
75
+
76
+ Steps that make no model call still get a row — `gather` is a real
77
+ step the user waits on, and a timeline that skips it is wrong.
78
+ """
79
+ step_id = runs.add_step(self.run_id, name)
80
+ ordinal = len(runs.list_steps(self.run_id))
81
+ open_step = _OpenStep(
82
+ step_id=step_id, ordinal=ordinal, started=time.monotonic()
83
+ )
84
+ self._open = open_step
85
+ self.emit({"type": "step-start", "name": name, "ordinal": ordinal})
86
+ try:
87
+ yield open_step
88
+ except StepFailure as exc:
89
+ self._close_step(open_step, status="failed", error=exc.code)
90
+ self.emit({
91
+ "type": "step-done", "name": name, "ordinal": ordinal,
92
+ "status": "failed", "error": exc.code,
93
+ })
94
+ raise
95
+ except Exception as exc:
96
+ self._close_step(open_step, status="failed", error=type(exc).__name__)
97
+ self.emit({
98
+ "type": "step-done", "name": name, "ordinal": ordinal,
99
+ "status": "failed", "error": type(exc).__name__,
100
+ })
101
+ raise
102
+ else:
103
+ latency = self._close_step(open_step, status="succeeded")
104
+ self.emit({
105
+ "type": "step-done", "name": name, "ordinal": ordinal,
106
+ "status": "succeeded", "latency_ms": latency,
107
+ "tokens_in": open_step.tokens_in or None,
108
+ "tokens_out": open_step.tokens_out or None,
109
+ })
110
+
111
+ def _close_step(
112
+ self, open_step: _OpenStep, *, status: str, error: str | None = None
113
+ ) -> int:
114
+ if self._open is open_step:
115
+ self._open = None
116
+ latency = int((time.monotonic() - open_step.started) * 1000)
117
+ runs.finish_step(
118
+ open_step.step_id,
119
+ status=status,
120
+ model=open_step.model,
121
+ tokens_in=open_step.tokens_in or None,
122
+ tokens_out=open_step.tokens_out or None,
123
+ latency_ms=latency,
124
+ error=error,
125
+ )
126
+ return latency
127
+
128
+ async def call_model(
129
+ self,
130
+ messages: list[dict],
131
+ contract: "contracts.TextContract | contracts.JsonContract",
132
+ *,
133
+ tool_definitions: list[dict] | None = None,
134
+ ) -> Any:
135
+ """One contract-validated completion, retried a bounded number of times.
136
+
137
+ Provider-side enforcement first (`response_format`), extraction second
138
+ (inside the contract), and only then a retry that quotes the exact
139
+ validation failure back.
140
+ """
141
+ conversation = list(messages)
142
+ attempts = (self.retries or 0) + 1
143
+ last_error = ""
144
+ for _ in range(attempts):
145
+ raw = await self._one_completion(conversation, contract, tool_definitions)
146
+ content = raw.get("content") or ""
147
+ try:
148
+ return contract.validate(content)
149
+ except contracts.ContractError as exc:
150
+ last_error = str(exc)
151
+ conversation = conversation + [
152
+ {"role": "assistant", "content": content},
153
+ contracts.repair_message(last_error),
154
+ ]
155
+ raise StepFailure(
156
+ "contract_invalid",
157
+ f"The model's output did not match the required format: {last_error}",
158
+ )
159
+
160
+ async def call_model_raw(
161
+ self, messages: list[dict], *, tool_definitions: list[dict] | None = None
162
+ ) -> dict:
163
+ """A completion with no contract — the agent scheduler's tool loop,
164
+ where the decision is 'which tool', not 'what shape'."""
165
+ return await self._one_completion(
166
+ messages, contracts.TextContract(), tool_definitions
167
+ )
168
+
169
+ async def _one_completion(
170
+ self, messages: list[dict], contract, tool_definitions: list[dict] | None
171
+ ) -> dict:
172
+ if self.calls_used >= (self.max_calls or 0):
173
+ raise BudgetExceeded(self.calls_used, self.max_calls or 0)
174
+ self.calls_used += 1
175
+ response_format = contract.response_format()
176
+ try:
177
+ message, usage = await openrouter.complete_message(
178
+ self.model, messages,
179
+ tools=tool_definitions, response_format=response_format,
180
+ )
181
+ # Specific subclasses before the shared parent: a routine 429 must not
182
+ # flatten into a generic outage, and the user needs the retry_after.
183
+ except openrouter.RateLimitedError as exc:
184
+ raise StepFailure(
185
+ "rate_limited",
186
+ "The model provider rate limited this run.",
187
+ exc.retry_after,
188
+ ) from exc
189
+ except openrouter.ModelGoneError as exc:
190
+ raise StepFailure(
191
+ "model_gone", f"The model {exc.model} is no longer available."
192
+ ) from exc
193
+ except openrouter.UpstreamError as exc:
194
+ raise StepFailure("upstream_error", str(exc)) from exc
195
+ if self._open is not None:
196
+ self._open.model = self.model
197
+ self._open.tokens_in += int(usage.get("prompt_tokens") or 0)
198
+ self._open.tokens_out += int(usage.get("completion_tokens") or 0)
199
+ return message
200
+
201
+ async def call_tool(self, name: str, arguments: dict) -> dict:
202
+ """Dispatch a tool, recording it as its own step row.
203
+
204
+ A tool call is a step so that every event the stream emits is
205
+ reconstructible from the database — which is what makes replay-then-
206
+ follow identical to a first connect.
207
+ """
208
+ parent = self._open
209
+ step_id = runs.add_step(self.run_id, f"tool:{name}")
210
+ ordinal = len(runs.list_steps(self.run_id))
211
+ started = time.monotonic()
212
+ self.emit({"type": "step-start", "name": f"tool:{name}", "ordinal": ordinal})
213
+ result = await self.registry.dispatch(name, arguments, owner=self.owner)
214
+ latency = int((time.monotonic() - started) * 1000)
215
+ failed = not result.get("ok")
216
+ error = result.get("error", {}).get("code") if failed else None
217
+ runs.finish_step(
218
+ step_id,
219
+ status="failed" if failed else "succeeded",
220
+ latency_ms=latency,
221
+ error=error,
222
+ )
223
+ self.emit({
224
+ "type": "step-done", "name": f"tool:{name}", "ordinal": ordinal,
225
+ "status": "failed" if failed else "succeeded",
226
+ "latency_ms": latency, "error": error,
227
+ })
228
+ # A tool failure is something the model sees and continues past, so it
229
+ # is returned, never raised. Restore the enclosing step for metrics.
230
+ self._open = parent
231
+ return result
232
+
233
+
234
+ class PipelineScheduler:
235
+ """Walks a declared step list. The most deterministic scheduler available:
236
+ call count and order are knowable before the run starts.
237
+
238
+ v0.7.0's ceiling arm pins this scheduler — a ceiling that drifts run to run
239
+ makes the three-arm comparison unreadable.
240
+ """
241
+
242
+ name = "pipeline"
243
+
244
+ def __init__(self, steps: list) -> None:
245
+ self.steps = steps
246
+
247
+ async def run(self, ctx: RunContext) -> dict:
248
+ output: dict = {}
249
+ for step in self.steps:
250
+ result = await step(ctx)
251
+ if isinstance(result, dict):
252
+ output = result
253
+ return output
254
+
255
+
256
+ def _tool_call_of(message: dict) -> dict | None:
257
+ """First well-formed tool call in a model reply, or None.
258
+
259
+ Model output, so every level may be the wrong shape — never raise while
260
+ inspecting one (same rule as chat's `_is_web_search_call`).
261
+ """
262
+ calls = message.get("tool_calls") if isinstance(message, dict) else None
263
+ for call in calls or []:
264
+ if not isinstance(call, dict):
265
+ continue
266
+ function = call.get("function")
267
+ if isinstance(function, dict) and function.get("name"):
268
+ return call
269
+ return None
270
+
271
+
272
+ class AgentScheduler:
273
+ """Loops with tools until a terminal tool or an iteration cap.
274
+
275
+ Ships as substrate in v0.6.0 with no skill declaring it: the loop gets
276
+ proven before any user-facing skill's quality depends on it.
277
+ """
278
+
279
+ name = "agent"
280
+
281
+ def __init__(
282
+ self,
283
+ system: str,
284
+ tool_names: list[str],
285
+ terminal_tool: str,
286
+ max_iterations: int | None = None,
287
+ ) -> None:
288
+ self.system = system
289
+ self.tool_names = tool_names
290
+ self.terminal_tool = terminal_tool
291
+ self.max_iterations = max_iterations
292
+
293
+ async def run(self, ctx: RunContext) -> dict:
294
+ cap = self.max_iterations or get_settings().skill_agent_max_iterations
295
+ definitions = [
296
+ d for d in ctx.registry.definitions(owner=ctx.owner)
297
+ if d["function"]["name"] in self.tool_names
298
+ ]
299
+ messages: list[dict] = [
300
+ {"role": "system", "content": self.system},
301
+ {"role": "user", "content": json.dumps(ctx.inputs)},
302
+ ]
303
+ for iteration in range(cap):
304
+ async with ctx.step(f"agent:{iteration + 1}"):
305
+ message = await ctx.call_model_raw(
306
+ messages, tool_definitions=definitions
307
+ )
308
+ call = _tool_call_of(message)
309
+ if call is None:
310
+ text = (message.get("content") or "").strip()
311
+ # No tool call AND real text is the agent answering
312
+ # directly — a legitimate end. But a reply that tried to
313
+ # call a tool and emitted a malformed payload, or returned
314
+ # nothing at all, is not an answer: saying so and letting
315
+ # it retry beats returning an empty result that reads as
316
+ # success.
317
+ if text and not message.get("tool_calls"):
318
+ return {"text": text}
319
+ messages = messages + [
320
+ {"role": "assistant", "content": message.get("content")},
321
+ {"role": "user", "content": (
322
+ "That reply contained no usable tool call. Either "
323
+ "call one of the available tools with valid JSON "
324
+ "arguments, or answer directly in plain text."
325
+ )},
326
+ ]
327
+ continue
328
+ name = call["function"]["name"]
329
+ try:
330
+ arguments = json.loads(call["function"].get("arguments") or "{}")
331
+ except json.JSONDecodeError:
332
+ arguments = {}
333
+ if not isinstance(arguments, dict):
334
+ arguments = {}
335
+ result = await ctx.call_tool(name, arguments)
336
+ if name == self.terminal_tool and result.get("ok"):
337
+ return result.get("data") or {}
338
+ messages = messages + [
339
+ {"role": "assistant", "content": None, "tool_calls": [call]},
340
+ {
341
+ "role": "tool",
342
+ "tool_call_id": call.get("id", ""),
343
+ # Tool results are data, not instructions.
344
+ "content": json.dumps(result),
345
+ },
346
+ ]
347
+ raise StepFailure(
348
+ "iteration_cap", f"The agent did not finish within {cap} iterations."
349
+ )
app/harness/runs.py CHANGED
@@ -272,3 +272,27 @@ def sweep_orphans() -> int:
272
  WHERE status = 'running'"""
273
  )
274
  return swept
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  WHERE status = 'running'"""
273
  )
274
  return swept
275
+
276
+
277
+ def cancel_run(run_id: int) -> None:
278
+ """Terminal, and deliberately distinct from 'failed': the user stopped this
279
+ on purpose, and run history should say so rather than implying a defect.
280
+
281
+ Only an active run is touched — cancelling an already-terminal run must not
282
+ rewrite a finished record.
283
+ """
284
+ with _connect() as conn:
285
+ conn.execute(
286
+ f"""UPDATE skill_runs
287
+ SET status = 'cancelled',
288
+ error_message = 'The run was cancelled.',
289
+ finished_at = datetime('now')
290
+ WHERE id = ? AND status IN ({','.join('?' * len(_ACTIVE))})""",
291
+ (run_id, *_ACTIVE),
292
+ )
293
+ conn.execute(
294
+ """UPDATE skill_run_steps
295
+ SET status = 'failed', error = 'cancelled'
296
+ WHERE run_id = ? AND status = 'running'""",
297
+ (run_id,),
298
+ )
app/services/openrouter.py CHANGED
@@ -72,10 +72,10 @@ async def stream_chat(
72
  yield delta
73
 
74
 
75
- async def _post_completion(payload: dict) -> dict:
76
- """POST a chat-completion payload to OpenRouter and return the raw assistant
77
- message dict. Owns the headers, timeout, and the full status-code ladder shared
78
- by `complete` and `complete_with_tools`."""
79
  settings = get_settings()
80
  headers = {
81
  "Authorization": f"Bearer {settings.openrouter_api_key}",
@@ -101,11 +101,56 @@ async def _post_completion(payload: dict) -> dict:
101
  try:
102
  # A 200 carrying a non-JSON body is just as malformed as a missing key,
103
  # and must not escape as a raw decode error mid-stream.
104
- return resp.json()["choices"][0]["message"]
105
- except (ValueError, KeyError, IndexError, TypeError) as exc:
 
 
 
 
 
 
 
 
 
 
106
  raise UpstreamError("malformed completion response") from exc
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  async def complete(model: str | None, messages: list[dict[str, str]]) -> str:
110
  """Run a single non-streaming OpenRouter chat completion, returning the text."""
111
  settings = get_settings()
 
72
  yield delta
73
 
74
 
75
+ async def _post_completion_body(payload: dict) -> dict:
76
+ """POST a chat-completion payload to OpenRouter and return the full decoded
77
+ body. Owns the headers, timeout, and the status-code ladder shared by every
78
+ caller."""
79
  settings = get_settings()
80
  headers = {
81
  "Authorization": f"Bearer {settings.openrouter_api_key}",
 
101
  try:
102
  # A 200 carrying a non-JSON body is just as malformed as a missing key,
103
  # and must not escape as a raw decode error mid-stream.
104
+ body = resp.json()
105
+ except ValueError as exc:
106
+ raise UpstreamError("malformed completion response") from exc
107
+ if not isinstance(body, dict):
108
+ raise UpstreamError("malformed completion response")
109
+ return body
110
+
111
+
112
+ def _message_of(body: dict) -> dict:
113
+ try:
114
+ return body["choices"][0]["message"]
115
+ except (KeyError, IndexError, TypeError) as exc:
116
  raise UpstreamError("malformed completion response") from exc
117
 
118
 
119
+ async def _post_completion(payload: dict) -> dict:
120
+ """The assistant message alone — what `complete`/`complete_with_tools` need."""
121
+ return _message_of(await _post_completion_body(payload))
122
+
123
+
124
+ async def complete_message(
125
+ model: str | None,
126
+ messages: list[dict],
127
+ *,
128
+ tools: list[dict] | None = None,
129
+ response_format: dict | None = None,
130
+ ) -> tuple[dict, dict]:
131
+ """One completion, returning the assistant message AND the usage block.
132
+
133
+ The harness records tokens per step, so discarding usage the way `complete`
134
+ does would leave every step row half empty.
135
+ """
136
+ settings = get_settings()
137
+ payload: dict = {
138
+ "model": model or settings.default_model,
139
+ "messages": messages,
140
+ "stream": False,
141
+ }
142
+ # Omitted rather than sent as null: some providers 400 on a null
143
+ # response_format instead of ignoring it.
144
+ if tools:
145
+ payload["tools"] = tools
146
+ payload["tool_choice"] = "auto"
147
+ if response_format:
148
+ payload["response_format"] = response_format
149
+ body = await _post_completion_body(payload)
150
+ usage = body.get("usage")
151
+ return _message_of(body), usage if isinstance(usage, dict) else {}
152
+
153
+
154
  async def complete(model: str | None, messages: list[dict[str, str]]) -> str:
155
  """Run a single non-streaming OpenRouter chat completion, returning the text."""
156
  settings = get_settings()
app/skills/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Skills: what the harness runs. Control flow is Python; prompt bodies are
2
+ markdown loaded at import."""
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Skill:
12
+ name: str
13
+ label: str
14
+ description: str
15
+ input_model: type[BaseModel]
16
+ scheduler: Any
17
+ estimated_calls: Callable[[dict], int]
18
+ owner_only: bool = True