merge: đồng bộ origin/gamma/refactor (R01/R03/R04 — routing unification,
conversation application service, AtomicJsonFile fix) vào sau khi tách 6 widget UI Co4E (N3) Đã kiểm trước khi merge: ui/co4e_tab.py và ui/routing_toggle.py đều bị 2 bên cùng đụng, nhưng ở vùng dòng khác nhau hoàn toàn (bên kia sửa _apply_co4e_routing/RoutingToggle cho R03-T05, N3 chỉ đụng phần dựng sidebar/canvas/chat) — không có xung đột logic thật. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Domain agents package: turn requests, agent events, and role definitions."""
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Typed events a turn emits while it runs (R04-T02).
|
||||
|
||||
The runtime currently speaks in bare dicts: ``emit({"type": "tool_result", "id":
|
||||
..., "ok": ...})``. Nothing declares which keys a given type carries, so the
|
||||
only specification is the 130-line ``if/elif`` chain in
|
||||
``ui/chat_panel.py::_on_event`` — and a typo in an emitter surfaces as a widget
|
||||
that silently renders nothing.
|
||||
|
||||
This module makes the vocabulary explicit. Each event is a frozen dataclass with
|
||||
real fields, and each one knows how to serialise itself back to the exact legacy
|
||||
dict the widget already reads (:meth:`AgentEvent.to_legacy_dict`), with
|
||||
:func:`from_legacy_dict` parsing the other way. That two-way bridge is what lets
|
||||
R04 introduce typed events WITHOUT touching the presentation layer — decomposing
|
||||
``_on_event`` into a renderer is R08-T01's job, and forcing both changes into one
|
||||
PR is exactly the "rewrite everything at once" the refactor plan forbids.
|
||||
|
||||
Scope note: this covers the interactive/scheduled **Cowork turn** vocabulary
|
||||
(the ``run_cowork`` path R04 unifies). Co4E's own node events (``node_status``,
|
||||
``stage_text``, ``run_done``) belong to ``Co4EWorkflowService`` in R07-T06 and
|
||||
are deliberately left as dicts here — :func:`from_legacy_dict` returns ``None``
|
||||
for them so a bridge can pass them straight through.
|
||||
|
||||
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
|
||||
layer, standard library only. No PySide6, no ``core/*`` imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple
|
||||
|
||||
# Notice levels. "progress" is special-cased by the UI (it retargets the live
|
||||
# thinking indicator instead of adding a bubble), so the vocabulary is pinned
|
||||
# here rather than left to each emitter's string literal.
|
||||
NOTICE_INFO = "info"
|
||||
NOTICE_WARNING = "warning"
|
||||
NOTICE_PROGRESS = "progress"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Value objects shared by several events.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolPreview:
|
||||
"""The human-readable preview of a proposed tool call.
|
||||
|
||||
Mirrors ``core/tools.py::describe_action``'s return shape exactly (three
|
||||
string keys, nothing else), so wrapping it in a type is lossless. ``kind``
|
||||
drives which bubble the UI renders: "diff" -> coloured before/after,
|
||||
"command" -> terminal block, "info" -> plain text.
|
||||
"""
|
||||
|
||||
kind: str = "info"
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {"kind": self.kind, "title": self.title, "text": self.text}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Any) -> Optional["ToolPreview"]:
|
||||
"""Parse a legacy preview dict; ``None`` when there was none.
|
||||
|
||||
A non-dict value degrades to ``None`` rather than raising: a malformed
|
||||
preview must cost the user a nicer bubble, never the whole turn.
|
||||
"""
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
return None
|
||||
return cls(kind=str(raw.get("kind", "info")), title=str(raw.get("title", "")),
|
||||
text=str(raw.get("text", "")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
"""One entry of the agent's ``update_plan`` checklist.
|
||||
|
||||
``status`` is kept a plain string on purpose: ``core/plan.py`` already owns
|
||||
validation (clamping anything unknown to "pending" against
|
||||
pending/running/done/error), and duplicating that vocabulary here would give
|
||||
the app two sources of truth to drift apart.
|
||||
"""
|
||||
|
||||
title: str
|
||||
status: str = "pending"
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {"title": self.title, "status": self.status}
|
||||
|
||||
|
||||
def _as_str_tuple(values: Iterable[Any]) -> Tuple[str, ...]:
|
||||
"""Freeze an iterable of paths into a tuple of strings.
|
||||
|
||||
Emitters hand us live lists (``record["outputs"]``, ``_cleanup``'s result);
|
||||
copying decouples the event from later mutation of that list.
|
||||
"""
|
||||
return tuple(str(v) for v in (values or ()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Base class.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class AgentEvent:
|
||||
"""Base for every turn event.
|
||||
|
||||
Not a dataclass itself (it holds no data) — subclasses are the frozen
|
||||
dataclasses. ``EVENT_TYPE`` is the legacy wire name, which stays the single
|
||||
identifier shared between the typed world and the dict world.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
"""Type-specific keys of the legacy dict (without ``type``)."""
|
||||
return {}
|
||||
|
||||
def to_legacy_dict(self) -> Dict[str, Any]:
|
||||
"""The exact dict shape ``ui/chat_panel.py::_on_event`` dispatches on."""
|
||||
return {"type": self.EVENT_TYPE, **self._payload()}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Streaming events.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TextChunkEvent(AgentEvent):
|
||||
"""A fragment of the assistant's visible answer."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "text"
|
||||
delta: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasoningChunkEvent(AgentEvent):
|
||||
"""A fragment of a reasoning model's thinking, shown in a collapsed box."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "reasoning"
|
||||
delta: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantMessageCompletedEvent(AgentEvent):
|
||||
"""One assistant message finished streaming.
|
||||
|
||||
Emitted once per provider call, so a tool-using turn produces SEVERAL of
|
||||
these — it marks an autosave point, not the end of the turn. The end of the
|
||||
turn is :class:`TurnCompletedEvent`.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "assistant_done"
|
||||
content: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"content": self.content}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool-call lifecycle.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallStartedEvent(AgentEvent):
|
||||
"""A tool call is about to run (after any security/permission gate).
|
||||
|
||||
Field names are the typed ones (``call_id``, ``arguments``); the legacy keys
|
||||
``id``/``args`` are produced only at the serialisation boundary, so new code
|
||||
never has to shadow the ``id`` builtin.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "tool_proposed"
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
||||
preview: Optional[ToolPreview] = None
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
|
||||
"args": dict(self.arguments)}
|
||||
# Omitted rather than sent as None: the widget does
|
||||
# ``preview = ev.get("preview") or {}`` and an absent key is the shape it
|
||||
# already handles for tools without a preview.
|
||||
if self.preview is not None:
|
||||
payload["preview"] = self.preview.to_dict()
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolOutputChunkEvent(AgentEvent):
|
||||
"""Live stdout/stderr from a running command, appended to its step bubble."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "tool_output"
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
delta: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"id": self.call_id, "name": self.name, "delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallFinishedEvent(AgentEvent):
|
||||
"""A tool call returned. ``path``/``produced`` name files it created."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "tool_result"
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
ok: bool = False
|
||||
output: str = ""
|
||||
path: str = "" # the single file this call wrote, if any
|
||||
produced: Tuple[str, ...] = () # extra deliverables a command produced
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Callers pass a live list; freeze it so the event cannot change later.
|
||||
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
|
||||
"ok": self.ok, "output": self.output}
|
||||
# Both keys stay ABSENT when empty, matching what chat_agent emits today:
|
||||
# downstream code tests them with ``ev.get(...)`` truthiness and iterates
|
||||
# ``ev.get("produced", [])``, so adding empty values would be a change.
|
||||
if self.path:
|
||||
payload["path"] = self.path
|
||||
if self.produced:
|
||||
payload["produced"] = list(self.produced)
|
||||
return payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Side-channel events (plan, notices, output folder).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class PlanUpdatedEvent(AgentEvent):
|
||||
"""The agent published a new version of its step checklist (full list)."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "plan_set"
|
||||
steps: Tuple[PlanStep, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "steps", tuple(self.steps or ()))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"steps": [s.to_dict() for s in self.steps]}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoticeEvent(AgentEvent):
|
||||
"""An aside outside the model's own answer.
|
||||
|
||||
Three sources today: context auto-compaction (info), a blocked
|
||||
security check (warning), and attachment reading progress (progress).
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "notice"
|
||||
text: str = ""
|
||||
level: str = NOTICE_INFO
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"level": self.level, "text": self.text}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputsAddedEvent(AgentEvent):
|
||||
"""Deliverables appeared in the turn's output folder."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "outputs_added"
|
||||
paths: Tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputsRemovedEvent(AgentEvent):
|
||||
"""Intermediate/generator files were cleaned up — drop them from Output."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "outputs_removed"
|
||||
paths: Tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryReadyEvent(AgentEvent):
|
||||
"""The turn's conversation now exists on disk and can be opened.
|
||||
|
||||
Emitted by the unattended (Schedule Task) path so the scheduler refreshes
|
||||
History only once the session is really there.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "history_ready"
|
||||
session_id: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"session_id": self.session_id}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Turn-level events introduced by R04 (no legacy consumer yet).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TurnCompletedEvent(AgentEvent):
|
||||
"""The whole turn ended — exactly once per turn.
|
||||
|
||||
Nothing consumes ``"turn_completed"`` yet: the widget's ``if/elif`` chain
|
||||
simply has no branch for it, so emitting it is inert until R08 wires a
|
||||
renderer. It exists now because the state it carries (was the turn
|
||||
cancelled? did it hit the step ceiling?) is currently reconstructed by the
|
||||
UI from side effects rather than being told to it.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "turn_completed"
|
||||
final_text: str = ""
|
||||
steps_used: int = 0
|
||||
cancelled: bool = False
|
||||
budget_exhausted: bool = False # stopped at effective_max_steps
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"final_text": self.final_text, "steps_used": self.steps_used,
|
||||
"cancelled": self.cancelled, "budget_exhausted": self.budget_exhausted}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorEvent(AgentEvent):
|
||||
"""The turn hit an error.
|
||||
|
||||
``recoverable`` separates "this turn is over" from "something failed but the
|
||||
loop carried on" — a distinction the current code loses, because both end up
|
||||
as a bare ``except Exception`` plus a text bubble.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "error"
|
||||
message: str = ""
|
||||
recoverable: bool = False
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"message": self.message, "recoverable": self.recoverable}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NOTICE_INFO", "NOTICE_WARNING", "NOTICE_PROGRESS",
|
||||
"AgentEvent", "ToolPreview", "PlanStep",
|
||||
"TextChunkEvent", "ReasoningChunkEvent", "AssistantMessageCompletedEvent",
|
||||
"ToolCallStartedEvent", "ToolOutputChunkEvent", "ToolCallFinishedEvent",
|
||||
"PlanUpdatedEvent", "NoticeEvent", "OutputsAddedEvent", "OutputsRemovedEvent",
|
||||
"HistoryReadyEvent", "TurnCompletedEvent", "ErrorEvent",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Legacy dict -> typed :mod:`agent_event` translation (R04-T02).
|
||||
|
||||
Kept in its own module for two reasons. It is a **temporary compatibility
|
||||
shim**: once R08-T01 turns ``ui/chat_panel.py::_on_event`` into an event
|
||||
renderer that consumes typed events directly, nothing needs to parse dicts any
|
||||
more and this whole file gets deleted — a deletion that stays trivial only while
|
||||
it is isolated. And it keeps ``agent_event.py`` inside the 400-LOC limit the
|
||||
architecture rules impose, without diluting either file's single job: one
|
||||
declares the vocabulary, the other bridges it to the old wire format.
|
||||
|
||||
Serialisation the other way lives on the events themselves
|
||||
(``AgentEvent.to_legacy_dict``), because an event has to be emittable without
|
||||
anyone importing a codec.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .agent_event import (
|
||||
AgentEvent,
|
||||
AssistantMessageCompletedEvent,
|
||||
ErrorEvent,
|
||||
HistoryReadyEvent,
|
||||
NOTICE_INFO,
|
||||
NoticeEvent,
|
||||
OutputsAddedEvent,
|
||||
OutputsRemovedEvent,
|
||||
PlanStep,
|
||||
PlanUpdatedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
ToolOutputChunkEvent,
|
||||
ToolPreview,
|
||||
TurnCompletedEvent,
|
||||
)
|
||||
|
||||
|
||||
def _plan_steps_from_legacy(raw: Any) -> Tuple[PlanStep, ...]:
|
||||
"""Parse the legacy ``steps`` list, dropping anything unusable.
|
||||
|
||||
A step with no title cannot be rendered or ticked off, so it is discarded
|
||||
instead of becoming a blank row in the Plan panel.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return ()
|
||||
steps: List[PlanStep] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title", "")).strip()
|
||||
if not title:
|
||||
continue
|
||||
steps.append(PlanStep(title=title, status=str(item.get("status", "pending"))))
|
||||
return tuple(steps)
|
||||
|
||||
|
||||
def _parse_tool_started(raw: Dict[str, Any]) -> ToolCallStartedEvent:
|
||||
"""Rebuild a ``tool_proposed`` event, mapping ``id``/``args`` to typed names."""
|
||||
args = raw.get("args")
|
||||
return ToolCallStartedEvent(
|
||||
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
|
||||
arguments=dict(args) if isinstance(args, dict) else {},
|
||||
preview=ToolPreview.from_dict(raw.get("preview")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_tool_finished(raw: Dict[str, Any]) -> ToolCallFinishedEvent:
|
||||
"""Rebuild a ``tool_result`` event; the optional file keys may be absent."""
|
||||
return ToolCallFinishedEvent(
|
||||
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
|
||||
ok=bool(raw.get("ok", False)), output=str(raw.get("output", "")),
|
||||
path=str(raw.get("path", "") or ""), produced=raw.get("produced") or (),
|
||||
)
|
||||
|
||||
|
||||
# One parser per wire name. A table (rather than an if/elif chain) keeps adding
|
||||
# an event a single-line change and makes the supported set introspectable.
|
||||
_PARSERS = {
|
||||
TextChunkEvent.EVENT_TYPE: lambda raw: TextChunkEvent(delta=str(raw.get("delta", ""))),
|
||||
ReasoningChunkEvent.EVENT_TYPE: lambda raw: ReasoningChunkEvent(
|
||||
delta=str(raw.get("delta", ""))),
|
||||
AssistantMessageCompletedEvent.EVENT_TYPE: lambda raw: AssistantMessageCompletedEvent(
|
||||
content=str(raw.get("content", ""))),
|
||||
ToolCallStartedEvent.EVENT_TYPE: _parse_tool_started,
|
||||
ToolOutputChunkEvent.EVENT_TYPE: lambda raw: ToolOutputChunkEvent(
|
||||
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
|
||||
delta=str(raw.get("delta", ""))),
|
||||
ToolCallFinishedEvent.EVENT_TYPE: _parse_tool_finished,
|
||||
PlanUpdatedEvent.EVENT_TYPE: lambda raw: PlanUpdatedEvent(
|
||||
steps=_plan_steps_from_legacy(raw.get("steps"))),
|
||||
NoticeEvent.EVENT_TYPE: lambda raw: NoticeEvent(
|
||||
text=str(raw.get("text", "")), level=str(raw.get("level", NOTICE_INFO))),
|
||||
OutputsAddedEvent.EVENT_TYPE: lambda raw: OutputsAddedEvent(paths=raw.get("paths") or ()),
|
||||
OutputsRemovedEvent.EVENT_TYPE: lambda raw: OutputsRemovedEvent(paths=raw.get("paths") or ()),
|
||||
HistoryReadyEvent.EVENT_TYPE: lambda raw: HistoryReadyEvent(
|
||||
session_id=str(raw.get("session_id", ""))),
|
||||
TurnCompletedEvent.EVENT_TYPE: lambda raw: TurnCompletedEvent(
|
||||
final_text=str(raw.get("final_text", "")), steps_used=int(raw.get("steps_used", 0) or 0),
|
||||
cancelled=bool(raw.get("cancelled", False)),
|
||||
budget_exhausted=bool(raw.get("budget_exhausted", False))),
|
||||
ErrorEvent.EVENT_TYPE: lambda raw: ErrorEvent(
|
||||
message=str(raw.get("message", "")), recoverable=bool(raw.get("recoverable", False))),
|
||||
}
|
||||
|
||||
|
||||
def from_legacy_dict(payload: Any) -> Optional[AgentEvent]:
|
||||
"""Parse an emitted dict into a typed event, or ``None`` if it isn't ours.
|
||||
|
||||
``None`` (rather than an exception) is the contract that makes incremental
|
||||
adoption possible: a bridge sitting between the runtime and the widget can
|
||||
type the events it recognises and forward everything else — Co4E's node
|
||||
events, or anything a future emitter adds — completely untouched.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
parser = _PARSERS.get(str(payload.get("type", "")))
|
||||
return parser(payload) if parser is not None else None
|
||||
|
||||
|
||||
__all__ = ["from_legacy_dict"]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""What one finished turn produced (R04-T03).
|
||||
|
||||
The outcome of a turn is currently spread over three shapes: ``run_cowork``
|
||||
returns the mutated message list, ``task_executors._run_agent`` returns a
|
||||
``(answer_text, timed_out, incomplete_reason)`` tuple, and the UI reconstructs
|
||||
the rest (did it get cancelled? did it hit the ceiling?) from side effects. Each
|
||||
caller therefore knows a slightly different amount about the same turn.
|
||||
|
||||
:class:`AgentResult` is the single answer. Frozen, like the request that started
|
||||
the turn, so a result cannot be edited into disagreeing with what actually
|
||||
happened.
|
||||
|
||||
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
|
||||
layer — standard library plus sibling domain types only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from .agent_event import PlanStep, TurnCompletedEvent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentResult:
|
||||
"""The outcome of one conversation turn."""
|
||||
|
||||
# The conversation AFTER the turn (system prompt, history, the new user
|
||||
# message, every assistant reply and tool result).
|
||||
messages: Tuple[Dict[str, Any], ...] = ()
|
||||
steps_used: int = 0 # provider calls this turn consumed
|
||||
cancelled: bool = False # the user pressed Stop
|
||||
budget_exhausted: bool = False # stopped at effective_max_steps
|
||||
# The agent's final checklist, so a caller can ask "did it really finish?"
|
||||
# (``core/plan.py::plan_incomplete_reason``) without replaying the events.
|
||||
plan_steps: Tuple[PlanStep, ...] = ()
|
||||
# Non-empty when the turn ended on a failure. A string rather than the
|
||||
# exception: the domain layer must not depend on where the error came from,
|
||||
# and the message is what every consumer (bubble, error.txt, audit) shows.
|
||||
error: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Freeze the collections the runtime hands over.
|
||||
|
||||
Both arrive as live lists that the caller keeps appending to after the
|
||||
turn (the UI merges messages back into its own history), so copying here
|
||||
is what keeps a result a record rather than a moving target.
|
||||
"""
|
||||
object.__setattr__(self, "messages", tuple(self.messages or ()))
|
||||
object.__setattr__(self, "plan_steps", tuple(self.plan_steps or ()))
|
||||
|
||||
@property
|
||||
def final_text(self) -> str:
|
||||
"""The answer to show the user.
|
||||
|
||||
Scans backwards for the last assistant message with real content, which
|
||||
is not the same as ``messages[-1]``: a turn that was cancelled or that
|
||||
ran out of steps mid-loop ends on a tool message, and a reasoning-only
|
||||
reply leaves a blank assistant message behind. Same rule as
|
||||
``core/task_executors.py::_last_assistant_text``, which this replaces.
|
||||
"""
|
||||
for message in reversed(self.messages):
|
||||
if message.get("role") == "assistant" and (message.get("content") or "").strip():
|
||||
return str(message["content"])
|
||||
return ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Whether the turn ran to a normal end.
|
||||
|
||||
Hitting the step ceiling still counts as ok: the agent did work and
|
||||
produced an answer, it just was not allowed to keep going — which the
|
||||
transcript says in its own note rather than by failing the turn.
|
||||
"""
|
||||
return not self.error and not self.cancelled
|
||||
|
||||
def to_turn_completed_event(self) -> TurnCompletedEvent:
|
||||
"""The end-of-turn event carrying this outcome to subscribers."""
|
||||
return TurnCompletedEvent(
|
||||
final_text=self.final_text, steps_used=self.steps_used,
|
||||
cancelled=self.cancelled, budget_exhausted=self.budget_exhausted,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentResult"]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""The immutable snapshot of ONE chat turn (R04-T01).
|
||||
|
||||
Today a turn's inputs live in a closure plus a 15-key ``ctx`` dict built inside
|
||||
``ui/chat_panel.py::_start_turn``, and the worker thread reads the widget back
|
||||
(``self._model``, ``self.title``, ``self.project_id``) while it runs. That is
|
||||
the mechanism behind the whole class of "I changed the model mid-answer and the
|
||||
running turn behaved oddly" reports: the turn has no snapshot of its own, so
|
||||
every later click on the UI is visible to work already in flight.
|
||||
|
||||
:class:`ConversationExecutionRequest` is that missing snapshot. Everything the
|
||||
runtime needs for one turn is captured once, on the UI thread, at submit time,
|
||||
and then handed to code that runs on a worker thread. Frozen, so no caller —
|
||||
widget or service — can retroactively change a decision the turn already acted
|
||||
on.
|
||||
|
||||
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this is
|
||||
the domain layer, so standard library only. No PySide6, no ``requests``, no
|
||||
filesystem access, and deliberately no import of ``core/*`` — a request only
|
||||
*describes* a turn; running it is the application layer's job
|
||||
(``application/conversations/``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# Separator between an instruction prefix (a ``/skill`` block, an ``/agent``
|
||||
# persona) and the user's own request. Kept as a constant because the prefix is
|
||||
# assembled in the presentation layer while the body is only known later on the
|
||||
# worker thread — both halves must agree on the exact separator or the model
|
||||
# sees a different prompt shape than it did before this refactor.
|
||||
PREFIX_SEPARATOR = "\n\n---\n\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationExecutionRequest:
|
||||
"""Everything needed to execute one conversation turn.
|
||||
|
||||
Frozen for the reason above; use :meth:`with_model` / :meth:`with_output_dir`
|
||||
to derive an adjusted copy rather than mutating one another thread may be
|
||||
reading.
|
||||
|
||||
Note on depth: ``messages`` is a *shallow* snapshot (a tuple holding the
|
||||
same message dicts the caller passed). That matches the existing
|
||||
``snapshot = list(self.messages)`` semantics in ``_start_turn`` exactly —
|
||||
the turn is protected from the history list being appended to or replaced,
|
||||
which is what actually happens between turns. Making it deep would silently
|
||||
change how ``_finalize_turn`` merges the turn's messages back, so the
|
||||
stronger guarantee is left to R04-T03 where that merge moves.
|
||||
"""
|
||||
|
||||
# -- identity ------------------------------------------------------- #
|
||||
turn_id: str # unique within a session ("t1", "t2", ...)
|
||||
session_id: str # the conversation this turn belongs to
|
||||
surface: str = "cowork" # routing/mode key: "cowork" | "co4e" | "ai_edit"
|
||||
project_id: str = "" # workspace the turn is confined to
|
||||
title: str = "" # conversation title; also names saved files
|
||||
|
||||
# -- what the user asked -------------------------------------------- #
|
||||
# The typed request, already stripped of any ``/skill`` or ``/agent``
|
||||
# directive (those become ``instruction_prefix``).
|
||||
prompt: str = ""
|
||||
instruction_prefix: str = "" # skill rules + agent persona for this turn
|
||||
# Prepended when the model/agent was switched mid-conversation, asking the
|
||||
# model to re-check the previous step before continuing. Invisible in the
|
||||
# chat bubble — it only travels in the payload sent to the provider.
|
||||
review_note: str = ""
|
||||
# Attachment PATHS, not their text: extracting a .docx can pip-install a
|
||||
# parser or shell out to LibreOffice, which must not run on the UI thread.
|
||||
# The runtime reads them later and passes the result to :meth:`user_content`.
|
||||
attachments: Tuple[str, ...] = ()
|
||||
# Conversation history as of submit time; the new user message is NOT part
|
||||
# of it (the runtime appends it once the body is composed).
|
||||
messages: Tuple[Dict[str, Any], ...] = ()
|
||||
|
||||
# -- which model answers -------------------------------------------- #
|
||||
# Already resolved upstream: an Admin-agent pin, the tab's own picker, or a
|
||||
# routing override published by ``RoutingApplicationService`` (R03). The
|
||||
# runtime does not re-decide, so a switch cannot land mid-turn.
|
||||
provider_id: str = ""
|
||||
model: str = "" # "" = the provider's configured default
|
||||
|
||||
# -- standing instructions ------------------------------------------ #
|
||||
project_context: str = "" # Claude-Projects-style shared instructions
|
||||
session_notes: str = "" # e.g. files this conversation already produced
|
||||
|
||||
# -- tool scope and turn limits -------------------------------------- #
|
||||
# None = every enabled built-in tool. An explicit (possibly empty) tuple
|
||||
# restricts the ADVERTISED tools, which is how a "read-only" step is made
|
||||
# literally unable to write.
|
||||
allowed_tools: Optional[Tuple[str, ...]] = None
|
||||
max_steps: int = 30 # interactive cap
|
||||
completion_max_steps: int = 200 # runaway ceiling for run-to-completion work
|
||||
run_to_completion: bool = False # Co4E flow steps need the higher ceiling
|
||||
enforce_rules: bool = True # False for sandboxed Co4E runs
|
||||
gate_mode: str = "auto" # "confirm" -> ask before run_command/install
|
||||
agent_role: str = "cowork" # audit-log attribution ("cowork" | "task" | ...)
|
||||
|
||||
# -- where its files go ---------------------------------------------- #
|
||||
output_dir: Optional[Path] = None # this turn's isolated sandbox
|
||||
home_output_root: Optional[Path] = None # conversation Output root to promote into
|
||||
|
||||
# -- unattended execution (Schedule Task) ----------------------------- #
|
||||
unattended: bool = False # no human watching; plan tracking is enforced
|
||||
timeout_sec: Optional[int] = None # None = no wall-clock limit
|
||||
|
||||
# Escape hatch for surface-specific data a future task needs to thread
|
||||
# through without another schema change (same role as
|
||||
# ``ProviderDescriptor.extras``).
|
||||
extras: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# -- validation / normalisation --------------------------------------- #
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject unusable requests and freeze the mutable inputs.
|
||||
|
||||
Validation lives here (not at the call site) so a request that exists is
|
||||
always safe to key by: the audit log, the History autosave and the
|
||||
per-turn output folder are all named from ``session_id``/``turn_id``.
|
||||
|
||||
Normalisation matters just as much: the caller hands us the composer's
|
||||
own attachment LIST and the live history LIST, and both get cleared or
|
||||
appended to for the next turn. Copying them into tuples here is what
|
||||
actually makes the snapshot a snapshot. ``object.__setattr__`` is the
|
||||
standard way to do this in a frozen dataclass.
|
||||
"""
|
||||
if not (self.turn_id or "").strip():
|
||||
raise ValueError("ConversationExecutionRequest.turn_id must not be empty")
|
||||
if not (self.session_id or "").strip():
|
||||
raise ValueError("ConversationExecutionRequest.session_id must not be empty")
|
||||
|
||||
object.__setattr__(self, "attachments", tuple(self.attachments or ()))
|
||||
object.__setattr__(self, "messages", tuple(self.messages or ()))
|
||||
# None must survive: it means "no restriction", while an empty tuple
|
||||
# means "deny every built-in tool" — two very different turns.
|
||||
if self.allowed_tools is not None:
|
||||
object.__setattr__(self, "allowed_tools", tuple(self.allowed_tools))
|
||||
# Accept str paths so a call site holding a config value does not have to
|
||||
# wrap it; everything downstream can then assume Path.
|
||||
for name in ("output_dir", "home_output_root"):
|
||||
value = getattr(self, name)
|
||||
if value is not None and not isinstance(value, Path):
|
||||
object.__setattr__(self, name, Path(value))
|
||||
|
||||
# -- derived turn policy ---------------------------------------------- #
|
||||
@property
|
||||
def has_prompt(self) -> bool:
|
||||
"""Whether the user actually typed something (an attachment-only turn
|
||||
legitimately has none). Mirrors ``RoutingRequest.has_prompt`` so both
|
||||
DTOs answer the "is there anything to work with?" question the same way.
|
||||
"""
|
||||
return bool((self.prompt or "").strip())
|
||||
|
||||
@property
|
||||
def effective_max_steps(self) -> int:
|
||||
"""The tool-use budget for this turn.
|
||||
|
||||
Run-to-completion work (a Co4E flow step whose single instruction may
|
||||
need many tool calls) gets the higher ceiling; interactive chat keeps the
|
||||
tight cap. Either way the turn still ends the moment the model stops
|
||||
calling tools — this is only the runaway limit.
|
||||
"""
|
||||
return self.completion_max_steps if self.run_to_completion else self.max_steps
|
||||
|
||||
@property
|
||||
def requires_permission_gate(self) -> bool:
|
||||
"""Whether ``run_command``/``install_package`` must be approved first.
|
||||
|
||||
Resolved by the caller (per-workspace Auto-run override, else the global
|
||||
"confirm before running commands" setting) and frozen here, so toggling
|
||||
the setting mid-turn cannot change the rules the turn started under.
|
||||
"""
|
||||
return self.gate_mode == "confirm"
|
||||
|
||||
# -- prompt composition ------------------------------------------------ #
|
||||
def user_content(self, body: str = "") -> str:
|
||||
"""The exact ``content`` to send as this turn's user message.
|
||||
|
||||
``body`` is the request text AFTER attachment extraction, which happens
|
||||
on the worker thread — hence a method taking it as an argument rather
|
||||
than a stored field. The assembly order reproduces the closure in
|
||||
``_start_turn`` byte for byte, because changing what a model receives is
|
||||
a behaviour change, not a refactor:
|
||||
|
||||
1. session notes are appended after the body;
|
||||
2. the instruction prefix goes in front, behind a fixed separator;
|
||||
3. the model-switch review note goes ahead of everything.
|
||||
"""
|
||||
content = body or ""
|
||||
notes = self.session_notes or ""
|
||||
if notes:
|
||||
# Guard the empty-body case (attachment-only turn) so the payload
|
||||
# never opens with a stray blank line.
|
||||
content = f"{content}\n\n{notes}" if content else notes
|
||||
prefix = self.instruction_prefix or ""
|
||||
if prefix:
|
||||
content = f"{prefix}{PREFIX_SEPARATOR}{content}"
|
||||
review = self.review_note or ""
|
||||
if review:
|
||||
content = f"{review}\n\n{content}"
|
||||
return content
|
||||
|
||||
# -- derivation --------------------------------------------------------- #
|
||||
def with_model(self, provider_id: str = "", model: str = "") -> "ConversationExecutionRequest":
|
||||
"""A copy pinned to another provider/model.
|
||||
|
||||
Needed when a decision lands between building the request and running it
|
||||
(a routing override, an Admin-agent pin). Deriving a new request keeps
|
||||
the "one turn, one immutable snapshot" rule intact instead of patching a
|
||||
request another thread may already hold.
|
||||
"""
|
||||
return replace(self, provider_id=provider_id or self.provider_id,
|
||||
model=model or self.model)
|
||||
|
||||
def with_output_dir(self, output_dir) -> "ConversationExecutionRequest":
|
||||
"""A copy writing into a different sandbox — used when the caller only
|
||||
learns the per-turn folder after the request is assembled."""
|
||||
return replace(self, output_dir=output_dir)
|
||||
|
||||
|
||||
__all__ = ["PREFIX_SEPARATOR", "ConversationExecutionRequest"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain models package: provider descriptors, model pricing, and routing metadata."""
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Provider catalog metadata — the domain-layer description of ONE LLM provider.
|
||||
|
||||
Before R03 the answer to "which providers exist, what do they cost, what can
|
||||
they do?" was spread over three places: the class table in
|
||||
``providers/factory.py``, the hand-maintained pricing table in
|
||||
``core/routing/metadata.py`` and a handful of ``if provider == "anthropic"``
|
||||
branches in the UI. :class:`ProviderDescriptor` is the single declarative
|
||||
record those call sites now read from.
|
||||
|
||||
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this
|
||||
module is 100% pure Python — no PySide6, no ``requests``, no filesystem, and no
|
||||
import of the concrete ``providers/*`` adapters. It only *describes* a provider;
|
||||
constructing one is the infrastructure layer's job
|
||||
(``infrastructure/providers/provider_registry.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
|
||||
class AuthKind(str, Enum):
|
||||
"""How a provider authenticates, so Settings/onboarding can ask for the
|
||||
right thing instead of hard-coding per-provider form fields.
|
||||
|
||||
Inherits ``str`` so a descriptor round-trips through JSON unchanged (the
|
||||
value is written as a plain string), matching how the routing models in
|
||||
``core/routing/models.py`` already serialize their enums.
|
||||
"""
|
||||
|
||||
NONE = "none" # local runtimes (Ollama) — nothing to supply
|
||||
API_KEY = "api_key" # bearer/x-api-key style secret
|
||||
OAUTH_TOKEN = "oauth" # token minted by an external login flow (Copilot)
|
||||
|
||||
|
||||
class WireProtocol(str, Enum):
|
||||
"""The on-the-wire dialect a provider speaks.
|
||||
|
||||
Several *distinct* providers share one protocol (Ollama, Codex, GitHub
|
||||
Copilot and generic gateways are all OpenAI Chat Completions), which is
|
||||
exactly why protocol is a separate field from the provider id: the registry
|
||||
picks the adapter class from the protocol, while everything user-facing
|
||||
keys off the id.
|
||||
"""
|
||||
|
||||
OPENAI_COMPAT = "openai_compat"
|
||||
ANTHROPIC = "anthropic"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDescriptor:
|
||||
"""Immutable metadata for one provider the app can route work to.
|
||||
|
||||
Frozen because descriptors are shared process-wide by the registry, the
|
||||
routing service and (eventually) the Settings screen; making them read-only
|
||||
removes any chance one caller mutates the catalog another caller is
|
||||
iterating. Use :meth:`with_models` to derive an updated copy instead.
|
||||
|
||||
Unknown pricing/context values stay ``None`` rather than being guessed —
|
||||
the routing scorer needs to distinguish "free" from "we don't know", the
|
||||
same contract ``core/routing/models.py::ModelMetadata`` already follows.
|
||||
"""
|
||||
|
||||
provider_id: str # config key, e.g. "anthropic"
|
||||
display_name: str # human label for Settings/UI
|
||||
wire_protocol: WireProtocol # which adapter class implements it
|
||||
auth_kind: AuthKind = AuthKind.API_KEY
|
||||
default_model: str = "" # used when no model is selected
|
||||
models: Tuple[str, ...] = () # known model ids (may be empty)
|
||||
max_context: Optional[int] = None # tokens; None = unknown
|
||||
cost_per_1k_input: Optional[float] = None # USD per 1K input tokens
|
||||
cost_per_1k_output: Optional[float] = None # USD per 1K output tokens
|
||||
supports_vision: bool = False
|
||||
supports_tools: bool = True
|
||||
supports_streaming: bool = True
|
||||
requires_base_url: bool = False # gateway endpoints must be configured
|
||||
# Extra ids that should resolve to this descriptor (renames/aliases kept for
|
||||
# backwards compatibility with configs written by older app versions).
|
||||
aliases: Tuple[str, ...] = ()
|
||||
# Free-form extension point so a team can attach provider-specific hints
|
||||
# without another schema migration.
|
||||
extras: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject descriptors that could never be looked up.
|
||||
|
||||
Raising here (rather than at registration time) means a malformed
|
||||
descriptor cannot exist at all, so every consumer downstream may assume
|
||||
``provider_id`` is a usable dict key.
|
||||
"""
|
||||
if not self.provider_id:
|
||||
raise ValueError("ProviderDescriptor.provider_id must not be empty")
|
||||
if not isinstance(self.wire_protocol, WireProtocol):
|
||||
raise TypeError("ProviderDescriptor.wire_protocol must be a WireProtocol")
|
||||
|
||||
# -- identity ------------------------------------------------------- #
|
||||
@property
|
||||
def identifiers(self) -> Tuple[str, ...]:
|
||||
"""Every id this descriptor answers to (canonical id first)."""
|
||||
return (self.provider_id, *self.aliases)
|
||||
|
||||
def matches(self, provider_id: str) -> bool:
|
||||
"""Case-insensitive id/alias match — config files and CLI flags are
|
||||
typed by humans, so lookup must not be case sensitive."""
|
||||
needle = (provider_id or "").strip().lower()
|
||||
return any(needle == known.lower() for known in self.identifiers)
|
||||
|
||||
# -- capability queries --------------------------------------------- #
|
||||
def knows_model(self, model_id: str) -> bool:
|
||||
"""Whether ``model_id`` is in this provider's declared catalog.
|
||||
|
||||
A miss is NOT proof the model is unusable: gateways expose models we
|
||||
cannot enumerate offline, so callers treat this as a hint (used to
|
||||
resolve a bare model id back to its provider) and never as a gate that
|
||||
blocks a request.
|
||||
"""
|
||||
needle = (model_id or "").strip().lower()
|
||||
return any(needle == known.strip().lower() for known in self.models)
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Capability check by name, mirroring the vocabulary the routing
|
||||
selector already filters on (``"vision"``, ``"tools"``, ``"streaming"``)
|
||||
so a descriptor can be fed straight into ``rank_models``."""
|
||||
return capability in self.capabilities
|
||||
|
||||
@property
|
||||
def capabilities(self) -> frozenset:
|
||||
"""Capability set in the same vocabulary as
|
||||
``core/routing/models.py::ModelMetadata.capabilities``."""
|
||||
caps = set()
|
||||
if self.supports_vision:
|
||||
caps.add("vision")
|
||||
if self.supports_tools:
|
||||
caps.add("tools")
|
||||
if self.supports_streaming:
|
||||
caps.add("streaming")
|
||||
return frozenset(caps)
|
||||
|
||||
@property
|
||||
def avg_cost_per_1k(self) -> Optional[float]:
|
||||
"""Blended input/output price, or ``None`` when either side is unknown.
|
||||
|
||||
Uses the same 1:3 input:output weighting as
|
||||
``ModelMetadata.avg_cost_per_1k`` so a descriptor and an assessment
|
||||
never disagree about what a model costs.
|
||||
"""
|
||||
ci, co = self.cost_per_1k_input, self.cost_per_1k_output
|
||||
if ci is None or co is None:
|
||||
return None
|
||||
return (ci + 3.0 * co) / 4.0
|
||||
|
||||
def resolve_model(self, requested: str = "") -> str:
|
||||
"""The model id to actually call: the caller's choice when they made
|
||||
one, otherwise this provider's default. Centralised here because every
|
||||
surface (chat, Co4E, AI-Edit) previously re-implemented the same
|
||||
``model or config_default`` fallback inline."""
|
||||
return (requested or "").strip() or self.default_model
|
||||
|
||||
# -- derivation / serialization ------------------------------------- #
|
||||
def with_models(self, models, *, default_model: str = "") -> "ProviderDescriptor":
|
||||
"""A copy carrying a freshly discovered model list.
|
||||
|
||||
Providers can enumerate their models at runtime (``list_models()``);
|
||||
because the descriptor is frozen, discovery produces a NEW descriptor
|
||||
that the registry swaps in atomically instead of mutating one that other
|
||||
threads may be reading.
|
||||
"""
|
||||
ordered = tuple(dict.fromkeys(m for m in models if m)) # de-dup, keep order
|
||||
chosen = default_model or self.default_model
|
||||
# Keep the default pointing at something real: fall back to the first
|
||||
# discovered model when the configured default vanished from the catalog.
|
||||
if ordered and chosen not in ordered:
|
||||
chosen = ordered[0]
|
||||
return replace(self, models=ordered, default_model=chosen)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""JSON-friendly view for config persistence and the Settings UI."""
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"display_name": self.display_name,
|
||||
"wire_protocol": self.wire_protocol.value,
|
||||
"auth_kind": self.auth_kind.value,
|
||||
"default_model": self.default_model,
|
||||
"models": list(self.models),
|
||||
"max_context": self.max_context,
|
||||
"cost_per_1k_input": self.cost_per_1k_input,
|
||||
"cost_per_1k_output": self.cost_per_1k_output,
|
||||
"capabilities": sorted(self.capabilities),
|
||||
"requires_base_url": self.requires_base_url,
|
||||
"aliases": list(self.aliases),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AuthKind", "WireProtocol", "ProviderDescriptor"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain security package: security policies, alert events, and permission types."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain tasks package: task definitions and deterministic schedule calculators."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain tools package: tool descriptors, capability scopes, and registry interfaces."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain workspaces package: immutable WorkspaceSession definitions."""
|
||||
Reference in New Issue
Block a user