docs: align runner guide with factory pattern

This commit is contained in:
banteg
2026-01-04 14:55:41 +04:00
parent 105466cc95
commit 465dda20a2
+59 -62
View File
@@ -138,6 +138,7 @@ Copy the Claude pattern: create a small dataclass to hold streaming state.
Common things to track: Common things to track:
- `factory`: `EventFactory` instance for creating Takopi events and tracking resume
- `pending_actions`: map tool_use_id → `Action` so tool results can complete them - `pending_actions`: map tool_use_id → `Action` so tool results can complete them
- `last_assistant_text`: fallback for final answer if the engine omits it - `last_assistant_text`: fallback for final answer if the engine omits it
- `note_seq`: counter used by `JsonlSubprocessRunner.note_event(...)` - `note_seq`: counter used by `JsonlSubprocessRunner.note_event(...)`
@@ -145,8 +146,11 @@ Common things to track:
```py ```py
from dataclasses import dataclass, field from dataclasses import dataclass, field
from ..events import EventFactory
@dataclass @dataclass
class PiStreamState: class PiStreamState:
factory: EventFactory = field(default_factory=lambda: EventFactory(ENGINE))
pending_actions: dict[str, Action] = field(default_factory=dict) pending_actions: dict[str, Action] = field(default_factory=dict)
last_assistant_text: str | None = None last_assistant_text: str | None = None
note_seq: int = 0 note_seq: int = 0
@@ -228,39 +232,30 @@ Use this mapping (mirrors Claudes approach):
Claude keeps translation logic in a standalone function (`translate_claude_event(...)`). Claude keeps translation logic in a standalone function (`translate_claude_event(...)`).
This makes it easy to unit test without spawning a subprocess. This makes it easy to unit test without spawning a subprocess.
Do the same for Pi: Do the same for Pi. Use pattern matching against msgspec shapes, and rely on the
`EventFactory` (as in Codex/Claude) to standardize event creation:
```py ```py
def translate_pi_event( def translate_pi_event(
event: dict[str, Any], event: pi_schema.PiEvent,
*, *,
title: str, title: str,
state: PiStreamState, state: PiStreamState,
factory: EventFactory,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
etype = event.get("type") match event:
case pi_schema.SessionStart(session_id=session_id, model=model):
if etype == "session.start":
session_id = event.get("session_id")
if not session_id: if not session_id:
return [] return []
model = event.get("model")
event_title = str(model) if model else title event_title = str(model) if model else title
return [ token = ResumeToken(engine=ENGINE, value=session_id)
StartedEvent( return [factory.started(token, title=event_title)]
engine=ENGINE,
resume=ResumeToken(engine=ENGINE, value=str(session_id)),
title=event_title,
)
]
if etype == "tool.use": case pi_schema.ToolUse(id=tool_id, name=name, input=tool_input):
tool_id = event.get("id") if not tool_id:
if not isinstance(tool_id, str) or not tool_id:
return [] return []
name = str(event.get("name") or "tool") tool_input = tool_input or {}
tool_input = event.get("input") name = str(name or "tool")
if not isinstance(tool_input, dict):
tool_input = {}
# Keep titles short and friendly. # Keep titles short and friendly.
# (Claude uses takopi.utils.paths.relativize_command / relativize_path) # (Claude uses takopi.utils.paths.relativize_command / relativize_path)
@@ -277,11 +272,19 @@ def translate_pi_event(
detail={"name": name, "input": tool_input}, detail={"name": name, "input": tool_input},
) )
state.pending_actions[action.id] = action state.pending_actions[action.id] = action
return [ActionEvent(engine=ENGINE, action=action, phase="started")] return [
factory.action_started(
action_id=action.id,
kind=action.kind,
title=action.title,
detail=action.detail,
)
]
if etype == "tool.result": case pi_schema.ToolResult(
tool_use_id = event.get("tool_use_id") tool_use_id=tool_use_id, content=content, is_error=is_error
if not isinstance(tool_use_id, str) or not tool_use_id: ):
if not tool_use_id:
return [] return []
action = state.pending_actions.pop(tool_use_id, None) action = state.pending_actions.pop(tool_use_id, None)
if action is None: if action is None:
@@ -292,61 +295,54 @@ def translate_pi_event(
detail={}, detail={},
) )
is_error = event.get("is_error") is True result_text = (
content = event.get("content") ""
result_text = "" if content is None else (content if isinstance(content, str) else str(content)) if content is None
else (content if isinstance(content, str) else str(content))
)
detail = dict(action.detail) detail = dict(action.detail)
detail.update({"result_preview": result_text, "is_error": is_error}) detail.update(
{"result_preview": result_text, "is_error": bool(is_error)}
)
return [ return [
ActionEvent( factory.action_completed(
engine=ENGINE, action_id=action.id,
action=Action(
id=action.id,
kind=action.kind, kind=action.kind,
title=action.title, title=action.title,
ok=not bool(is_error),
detail=detail, detail=detail,
),
phase="completed",
ok=not is_error,
) )
] ]
if etype == "final": case pi_schema.Final(session_id=session_id, ok=ok, answer=answer, error=error):
ok = event.get("ok") is True answer = answer or ""
answer = event.get("answer")
if not isinstance(answer, str):
answer = ""
if ok and not answer and state.last_assistant_text: if ok and not answer and state.last_assistant_text:
answer = state.last_assistant_text answer = state.last_assistant_text
session_id = event.get("session_id")
resume = ( resume = (
ResumeToken(engine=ENGINE, value=str(session_id)) if session_id else None ResumeToken(engine=ENGINE, value=session_id) if session_id else None
) )
error = None if ok:
if not ok: return [factory.completed_ok(answer=answer, resume=resume)]
err = event.get("error")
error = str(err) if err else "pi run failed"
error_text = str(error) if error else "pi run failed"
return [ return [
CompletedEvent( factory.completed_error(
engine=ENGINE, error=error_text,
ok=ok,
answer=answer, answer=answer,
resume=resume, resume=resume,
error=error,
) )
] ]
case _:
return [] return []
``` ```
This is intentionally close to Claudes structure: This is intentionally close to Claudes structure:
- Parse `type` - Match on the msgspec event type
- Handle “init/session start” first - Handle “init/session start” first
- Emit action-start and action-complete events - Emit action-start and action-complete events
- Emit a final `CompletedEvent` - Emit a final `CompletedEvent`
@@ -377,17 +373,14 @@ import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..model import ( from ..model import (
CompletedEvent,
EngineId, EngineId,
ResumeToken, ResumeToken,
StartedEvent,
TakopiEvent, TakopiEvent,
) )
import msgspec
from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner from ..runner import JsonlSubprocessRunner, ResumeTokenMixin, Runner
from ..schemas import pi as pi_schema from ..schemas import pi as pi_schema
@@ -458,21 +451,25 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
raw: bytes, raw: bytes,
line: bytes, line: bytes,
state: PiStreamState, state: PiStreamState,
) -> dict[str, Any] | None: ) -> pi_schema.PiEvent | None:
_ = raw, state _ = raw, state
event = pi_schema.decode_event(line) return pi_schema.decode_event(line)
return cast(dict[str, Any], msgspec.to_builtins(event))
def translate( def translate(
self, self,
data: dict[str, Any], data: pi_schema.PiEvent,
*, *,
state: PiStreamState, state: PiStreamState,
resume: ResumeToken | None, resume: ResumeToken | None,
found_session: ResumeToken | None, found_session: ResumeToken | None,
) -> list[TakopiEvent]: ) -> list[TakopiEvent]:
_ = resume, found_session _ = resume, found_session
return translate_pi_event(data, title=self.session_title, state=state) return translate_pi_event(
data,
title=self.session_title,
state=state,
factory=state.factory,
)
``` ```
Notes: Notes: