cmpatino's picture
cmpatino HF Staff
deploy controller (2e85dec)
3ed7ad0 verified
Raw History Blame Contribute Delete
4.12 kB
"""The seam between the worker and a harness process.
An adapter owns at most one live harness process at a time (one burst). It is reusable:
`start` after `stop` begins a new burst on the same instance. Events come out of
`events()` until the process exits; the last event of a burst is always `exited`.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Protocol, runtime_checkable
from flock.models import TokenUsage, TypeSpec
EventKind = Literal["turn_started", "message", "usage", "turn_ended", "error", "exited"]
@dataclass
class AdapterEvent:
"""One harness-neutral event.
- turn_started: the harness began a turn (data may carry the harness's own turn id).
- message: `message` is a pi-format AgentMessage (role user/assistant/toolResult).
- usage: `usage` tokens for `model`. `cumulative` means a running total for the whole
session (the runtime subtracts the previous snapshot); `baseline` means a snapshot that
predates this burst and must only reset the reference point, never be counted.
`cost_usd` is the harness's own cumulative estimate, if any.
- turn_ended: `status` is completed | error | interrupted | ...
- error: `error` text; the process may still be alive.
- exited: the process is gone; no more events this burst.
"""
kind: EventKind
message: dict[str, Any] | None = None
model: str | None = None
usage: TokenUsage | None = None
cumulative: bool = False
baseline: bool = False
cost_usd: float | None = None
status: str | None = None
error: str | None = None
data: dict[str, Any] = field(default_factory=dict)
raw: Any = None
class ResumeError(RuntimeError):
"""The harness could not resume the requested session; start fresh instead."""
@runtime_checkable
class HarnessAdapter(Protocol):
harness: str
home: Path # root of the harness's own state; session_files are synced relative to it
async def start(
self,
workdir: Path,
session_id: str | None,
spec: TypeSpec,
*,
env: dict[str, str] | None = None,
) -> str:
"""Start a process. Resume `session_id` (raise ResumeError if impossible) or create a
new session. Returns the session id in use."""
...
async def send(self, text: str) -> None: ...
async def stop(self) -> None:
"""End the process (graceful, then killed). Idempotent."""
...
def events(self) -> AsyncIterator[AdapterEvent]: ...
def session_files(self, session_id: str | None) -> list[Path]:
"""Harness files that must be synced for `session_id` to resume elsewhere."""
...
class EventQueue:
"""Small helper both adapters use: a queue whose iterator ends after `exited`."""
def __init__(self) -> None:
self._q: asyncio.Queue[AdapterEvent] = asyncio.Queue()
def put(self, ev: AdapterEvent) -> None:
self._q.put_nowait(ev)
async def __aiter__(self) -> AsyncIterator[AdapterEvent]:
while True:
ev = await self._q.get()
yield ev
if ev.kind == "exited":
return
def usage_sub(a: TokenUsage, b: TokenUsage | None) -> TokenUsage:
"""a - b, clamped at zero per field (cumulative counters never go backwards)."""
if b is None:
return a
return TokenUsage(**{k: max(0, getattr(a, k) - getattr(b, k)) for k in TokenUsage.model_fields})
def pi_usage(u: TokenUsage) -> dict[str, Any]:
"""TokenUsage as a pi `usage` object (cost left at zero; the controller prices tokens)."""
d: dict[str, Any] = {
"input": u.input,
"output": u.output,
"cacheRead": u.cache_read,
"cacheWrite": u.cache_write,
"totalTokens": u.input + u.output + u.cache_read + u.cache_write,
"cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
}
if u.reasoning:
d["reasoning"] = u.reasoning
return d