## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Domain layer - pure Python entities, value objects and events.
|
||||
|
||||
The innermost layer of the 4-tier architecture (see
|
||||
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
|
||||
WHAT the application is about - a turn of conversation, a model candidate, an
|
||||
agent event - and depend on nothing but the standard library.
|
||||
|
||||
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
|
||||
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
|
||||
``presentation/`` or the legacy ``core/``/``ui/`` packages.
|
||||
"""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Domain agents package: turn requests, agent events, and role definitions."""
|
||||
|
||||
from .agent_event import (
|
||||
NOTICE_INFO,
|
||||
NOTICE_PROGRESS,
|
||||
NOTICE_WARNING,
|
||||
AgentEvent,
|
||||
AssistantMessageCompletedEvent,
|
||||
ErrorEvent,
|
||||
HistoryReadyEvent,
|
||||
NoticeEvent,
|
||||
OutputsAddedEvent,
|
||||
OutputsRemovedEvent,
|
||||
PlanStep,
|
||||
PlanUpdatedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
ToolOutputChunkEvent,
|
||||
ToolPreview,
|
||||
TurnCompletedEvent,
|
||||
)
|
||||
from .conversation_execution_request import (
|
||||
PREFIX_SEPARATOR,
|
||||
ConversationExecutionRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"NOTICE_INFO",
|
||||
"NOTICE_PROGRESS",
|
||||
"NOTICE_WARNING",
|
||||
"PREFIX_SEPARATOR",
|
||||
"AgentEvent",
|
||||
"AssistantMessageCompletedEvent",
|
||||
"ConversationExecutionRequest",
|
||||
"ErrorEvent",
|
||||
"HistoryReadyEvent",
|
||||
"NoticeEvent",
|
||||
"OutputsAddedEvent",
|
||||
"OutputsRemovedEvent",
|
||||
"PlanStep",
|
||||
"PlanUpdatedEvent",
|
||||
"ReasoningChunkEvent",
|
||||
"TextChunkEvent",
|
||||
"ToolCallFinishedEvent",
|
||||
"ToolCallStartedEvent",
|
||||
"ToolOutputChunkEvent",
|
||||
"ToolPreview",
|
||||
"TurnCompletedEvent",
|
||||
]
|
||||
@@ -0,0 +1,389 @@
|
||||
"""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]:
|
||||
"""Ba khoá đúng như ``core/tools.py::describe_action`` trả về — không thêm không bớt."""
|
||||
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]:
|
||||
"""Một bước kế hoạch dưới dạng dict, để đưa vào payload sự kiện."""
|
||||
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]:
|
||||
"""``{delta}`` — một mẩu câu trả lời đang phát dần."""
|
||||
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]:
|
||||
"""``{delta}`` — một mẩu suy luận nội bộ của model."""
|
||||
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]:
|
||||
"""``{content}`` — nội dung trọn vẹn của một lượt gọi provider."""
|
||||
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]:
|
||||
"""``{id, name, args}``, kèm ``preview`` chỉ khi thật sự có.
|
||||
|
||||
Không có xem trước thì BỎ HẲN khoá thay vì gửi ``None``: widget đang viết
|
||||
``ev.get("preview") or {}``, tức nó đã quen với việc khoá vắng mặt.
|
||||
"""
|
||||
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]:
|
||||
"""``{id, name, delta}`` — một mẩu đầu ra tool đang chạy."""
|
||||
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.
|
||||
"""Đóng băng danh sách tệp đầu ra thành tuple.
|
||||
|
||||
Bên gọi truyền vào một list đang sống; không sao chép thì sự kiện đã phát đi
|
||||
vẫn đổi nội dung được về sau — sự kiện phải là ảnh chụp bất biến.
|
||||
"""
|
||||
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
"""``{id, name, ok, output}``, kèm ``error``/``produced`` chỉ khi có.
|
||||
|
||||
Hai khoá sau vắng mặt khi rỗng, đúng như ``chat_agent`` vẫn phát: phía
|
||||
nhận kiểm bằng ``ev.get(...)`` nên thêm giá trị rỗng là đổi hành vi.
|
||||
"""
|
||||
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:
|
||||
"""Đóng băng danh sách bước kế hoạch thành tuple."""
|
||||
object.__setattr__(self, "steps", tuple(self.steps or ()))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
"""``{steps}`` — cả danh sách bước, vì agent gửi lại trọn kế hoạch mỗi lần cập nhật."""
|
||||
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]:
|
||||
"""``{level, text}`` — một dòng thông báo (info / cảnh báo / tiến độ)."""
|
||||
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:
|
||||
"""Đóng băng danh sách đường dẫn thành tuple."""
|
||||
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
"""``{paths}`` — các tệp vừa sinh ra, đổi về list cho JSON hoá được."""
|
||||
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:
|
||||
"""Đóng băng danh sách đường dẫn thành tuple."""
|
||||
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
"""``{paths}`` — các tệp trung gian vừa được dọn."""
|
||||
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]:
|
||||
"""``{session_id}`` — lịch sử đã ghi xong, kèm id phiên để mở lại."""
|
||||
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]:
|
||||
"""``{final_text, steps_used, cancelled, budget_exhausted}`` — tổng kết cả lượt."""
|
||||
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]:
|
||||
"""``{message, recoverable}`` — lỗi kèm cờ có thể thử lại hay không."""
|
||||
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,133 @@
|
||||
"""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.
|
||||
|
||||
SEAM · dựng 2026-08-23 · chưa nối dây (F-05)
|
||||
------------------------------------------------------------
|
||||
Được nối khi: ``presentation/chat`` dùng thẳng sự kiện có kiểu, không còn đọc dict cũ nữa.
|
||||
Để dormant thì sao: Shim này để xoá, không để giữ. Còn nó thì khuôn dict cũ
|
||||
vẫn là một hợp đồng phải duy trì.
|
||||
|
||||
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
|
||||
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
|
||||
đọc theo — đừng sửa ngày để làm im lời nhắc.
|
||||
"""
|
||||
|
||||
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,13 @@
|
||||
"""Domain models package: provider descriptors, model pricing, and routing metadata."""
|
||||
|
||||
from .provider_descriptor import (
|
||||
AuthKind,
|
||||
ProviderDescriptor,
|
||||
WireProtocol,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthKind",
|
||||
"ProviderDescriptor",
|
||||
"WireProtocol",
|
||||
]
|
||||
@@ -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,128 @@
|
||||
"""Cổng chính sách cho lời gọi tool — hình dạng dữ liệu, chưa phải cài đặt.
|
||||
|
||||
BẢN ĐỀ XUẤT, chờ Team Hoa xác nhận
|
||||
==================================
|
||||
Sơ đồ phân hệ trong ``plan.md`` giao ``domain/security/`` cho Team Gamma và
|
||||
``application/conversations/tool_policy_gateway.py`` cho Team Hoa. Nên Gamma
|
||||
định nghĩa *hình dạng*, Hoa *cài đặt*.
|
||||
|
||||
Viết trước vì N3 (Co4E) cần gọi tool và Team Hoa chưa bắt đầu. Không có nó thì
|
||||
N3 phải tự phỏng đoán rồi sửa lại sau — mà phỏng đoán của một người thì tệ hơn
|
||||
một đề xuất viết ra để cả hai bên soi.
|
||||
|
||||
Nếu Hoa thấy khác, sửa file này chứ đừng đẻ kiểu thứ hai. Đổi sớm rẻ hơn đổi
|
||||
muộn: hiện chỉ N3 dùng.
|
||||
|
||||
Mô hình bám theo code đang chạy, không bịa:
|
||||
* ``core/agent_security.py::SecurityVerdict`` — allowed / reason / layer
|
||||
* ``ui/permission_dialog.py`` — hộp thoại hỏi người dùng khi
|
||||
``ctx.project_confirm_commands()`` bật (``ui/chat_panel.py:1312``)
|
||||
|
||||
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
|
||||
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
|
||||
|
||||
SEAM · dựng 2026-08-21 · chưa nối dây (F-05)
|
||||
------------------------------------------------------------
|
||||
Được nối khi: ``application/conversations/tool_policy_gateway.py`` trả về ``PolicyDecision`` thay cho ``bool``.
|
||||
Để dormant thì sao: Hiện gateway chỉ trả đúng/sai nên lý do chặn bị mất —
|
||||
đúng thứ kiểu dữ liệu này sinh ra để mang theo.
|
||||
|
||||
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
|
||||
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
|
||||
đọc theo — đừng sửa ngày để làm im lời nhắc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class PolicyOutcome(str, Enum):
|
||||
"""Ba trạng thái. ``ASK`` là thứ hệ thống hiện tại đã có (hộp thoại xin
|
||||
phép) nhưng chưa được coi là một kết quả chính thức."""
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallRequest:
|
||||
"""Một lời gọi tool đang chờ được duyệt.
|
||||
|
||||
``surface`` cho biết chỗ phát sinh — ``"cowork"``, ``"code"``, ``"co4e"``,
|
||||
``"task"``. Chính sách khác nhau theo màn: Co4E chạy nền nên không thể bật
|
||||
hộp thoại hỏi giữa chừng như Cowork.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
||||
surface: str = "cowork"
|
||||
project_id: str = ""
|
||||
#: True nếu tool đến từ MCP server ngoài, False nếu là tool dựng sẵn.
|
||||
external: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
"""Câu trả lời của cổng.
|
||||
|
||||
``reason`` bắt buộc có khi DENY hoặc ASK — người dùng phải biết vì sao bị
|
||||
chặn, và ``core/audit_log.py`` cần nó để ghi lại.
|
||||
|
||||
``layer`` giữ đúng từ vựng của ``SecurityVerdict``: ``"prompt"`` |
|
||||
``"attachment"`` | ``"command"``, cộng thêm ``"policy"`` cho quyết định của
|
||||
chính cổng này.
|
||||
"""
|
||||
|
||||
outcome: PolicyOutcome
|
||||
reason: str = ""
|
||||
layer: str = "policy"
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
"""Tương thích với chỗ đang đọc ``SecurityVerdict.allowed``.
|
||||
|
||||
Chú ý: ``ASK`` KHÔNG phải allowed — còn phải hỏi người dùng đã.
|
||||
"""
|
||||
return self.outcome is PolicyOutcome.ALLOW
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ép mọi quyết định DENY/ASK phải kèm lý do.
|
||||
|
||||
Người dùng thấy lý do trên hộp thoại, và nhật ký kiểm toán ghi lại nó — một
|
||||
quyết định chặn không lý do là không truy được về sau.
|
||||
"""
|
||||
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
|
||||
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
|
||||
"audit log đều cần biết vì sao")
|
||||
|
||||
|
||||
def allow() -> PolicyDecision:
|
||||
"""Quyết định cho phép. Không cần lý do: đây là đường đi bình thường."""
|
||||
return PolicyDecision(PolicyOutcome.ALLOW)
|
||||
|
||||
|
||||
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
"""Quyết định chặn hẳn, kèm lý do và tên lớp đã ra quyết định."""
|
||||
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
|
||||
|
||||
|
||||
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
"""Quyết định phải hỏi người dùng, kèm lý do và tên lớp đã ra quyết định."""
|
||||
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolPolicyGateway(Protocol):
|
||||
"""Hỏi trước khi chạy tool. Cài đặt thật: Team Hoa (R07, hạn 29/08)."""
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
"""Được chạy tool này không.
|
||||
|
||||
KHÔNG được tự bật hộp thoại bên trong — cổng chỉ *trả lời*, còn hỏi ai
|
||||
và hỏi thế nào là việc của tầng giao diện. Có vậy thì Co4E chạy nền mới
|
||||
dùng chung cổng được với Cowork chạy tương tác.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities for schedule/due-time computation (EPIC R07)."""
|
||||
|
||||
from .schedule_calculator import ScheduleCalculator
|
||||
|
||||
__all__ = ["ScheduleCalculator"]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""ScheduleCalculator - due-time / cron / interval math for Schedule Task,
|
||||
extracted from ``core/tasks.py``'s "schedule math" section (R07-T02).
|
||||
|
||||
``core/tasks.py`` is already Qt-free (its own docstring says so), but it
|
||||
still lives under ``core/`` where nothing enforces that "pure" claim - and it
|
||||
is the ONE piece of scheduling logic ``docs/refactor/plan.md`` calls out as
|
||||
needing its own unit tests (none existed before this task; see
|
||||
``tests/unit/test_schedule_calculator.py``). Moving it to ``domain/tasks/``
|
||||
makes the purity a build-time guarantee (``scripts/check_imports.py`` fails
|
||||
the build if this file ever imports Qt, ``core``, or anything with I/O) and
|
||||
gives the date math a home that is trivially unit-testable without going
|
||||
through ``core/tasks.py``'s file-repository concerns at all.
|
||||
|
||||
Two pieces of this math are themselves implemented elsewhere in ``core/`` -
|
||||
``core/cron.py::Cron`` (5-field cron parsing) and
|
||||
``core/holiday_calendar.py::is_holiday`` (VN public holidays). Importing
|
||||
``core`` from ``domain`` is exactly what ADR-001 rule I2 forbids (domain must
|
||||
not know infrastructure/core exists), so this class takes them as
|
||||
constructor-injected callables instead of importing them - the same
|
||||
dependency-inversion shape ``application/conversations/conversation_
|
||||
application_service.py`` (R04-T03) already uses for its provider factory.
|
||||
``core/tasks.py`` wires the real ``Cron``/``is_holiday`` in; tests can inject
|
||||
plain stub functions with zero I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol
|
||||
|
||||
# Same on-disk format core/tasks.py::_TIME_FMT uses for schedule.run_at.
|
||||
# Duplicated here (not imported - that would be a domain -> core edge) since
|
||||
# it's a 1-line format string, not business logic.
|
||||
_TIME_FMT = "%Y-%m-%d %H:%M"
|
||||
|
||||
_CRON_SEARCH_GUARD = 400 # matches the guard core/tasks.py used before extraction
|
||||
|
||||
|
||||
class _CronLike(Protocol):
|
||||
"""Structural shape this class needs from a cron object - satisfied by
|
||||
``core/cron.py::Cron`` without this module importing it."""
|
||||
|
||||
def next_after(self, after: datetime) -> Optional[datetime]:
|
||||
"""Lần chạy kế tiếp sau một mốc thời gian; ``None`` nếu không bao giờ."""
|
||||
...
|
||||
|
||||
|
||||
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
|
||||
"""Đọc chuỗi thời gian chạy thành ``datetime``; sai định dạng thì trả ``None``."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, _TIME_FMT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ScheduleCalculator:
|
||||
"""Pure due-time computation for one task's ``schedule`` dict.
|
||||
|
||||
``is_holiday``: ``Callable[[date, country_code], bool]`` or ``None`` -
|
||||
when ``None``, a schedule with ``skip_holidays`` set simply never treats
|
||||
any day as a holiday (degrades gracefully instead of raising, mirroring
|
||||
how a caller who doesn't care about holidays can just not wire it up).
|
||||
|
||||
``make_cron``: ``Callable[[str], _CronLike]`` (raises on a malformed
|
||||
expression) or ``None`` - when ``None``, ``repeat_type == "cron"``
|
||||
schedules never produce a next run (same as an invalid expression today).
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
is_holiday: Optional[Callable[[Any, str], bool]] = None,
|
||||
make_cron: Optional[Callable[[str], _CronLike]] = None) -> None:
|
||||
"""``is_holiday``/``make_cron`` tiêm được nên lớp này không phụ thuộc vào lịch
|
||||
nghỉ hay bộ phân tích cron nào cụ thể — test truyền hàm giả vào.
|
||||
"""
|
||||
self._is_holiday = is_holiday
|
||||
self._make_cron = make_cron
|
||||
|
||||
def is_excluded_day(self, dt: datetime, sched: Dict[str, Any]) -> bool:
|
||||
"""True when ``dt`` falls on a day this schedule must skip: a
|
||||
weekend (working_days_only) or a public holiday of the configured
|
||||
country."""
|
||||
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
return True
|
||||
if sched.get("skip_holidays") and self._is_holiday is not None:
|
||||
if self._is_holiday(dt.date(), sched.get("holiday_country", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_month(self, dt: datetime) -> datetime:
|
||||
"""Calendar-aware +1 month, clamping the day to the target month's
|
||||
length (e.g. Jan 31 + 1 month -> Feb 28/29, not an overflow error)."""
|
||||
year = dt.year + (1 if dt.month == 12 else 0)
|
||||
month = 1 if dt.month == 12 else dt.month + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
|
||||
def shift_off_excluded_days(self, dt: datetime, sched: Dict[str, Any]) -> datetime:
|
||||
"""Push ``dt`` forward one day at a time until it lands on an
|
||||
allowed day (same time of day) - used for one-time schedules set on
|
||||
a weekend/holiday."""
|
||||
guard = 0
|
||||
while self.is_excluded_day(dt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
dt += timedelta(days=1)
|
||||
guard += 1
|
||||
return dt
|
||||
|
||||
def compute_next_run(self, task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
||||
"""The next run time strictly after ``after`` for a repeating task
|
||||
(daily / weekly / monthly / cron), or ``None`` for one-shot
|
||||
schedules. Occurrences on excluded days are skipped forward."""
|
||||
sched = task.get("schedule", {})
|
||||
repeat = sched.get("repeat_type", "none")
|
||||
|
||||
if repeat == "cron":
|
||||
if self._make_cron is None:
|
||||
return None
|
||||
try:
|
||||
cron = self._make_cron(sched.get("cron_expression") or "")
|
||||
except Exception:
|
||||
# Any malformed-expression error the injected factory raises
|
||||
# (core/cron.py::CronError, or a fake's own error type in
|
||||
# tests) means "this schedule can't compute a next run" - not
|
||||
# a domain-layer crash.
|
||||
return None
|
||||
nxt = cron.next_after(after)
|
||||
guard = 0
|
||||
while nxt is not None and self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
nxt = cron.next_after(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
base = _parse_run_at(sched.get("run_at"))
|
||||
if base is None:
|
||||
return None
|
||||
if repeat == "daily":
|
||||
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
||||
elif repeat == "weekly":
|
||||
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
||||
elif repeat == "monthly":
|
||||
advance = self.add_month
|
||||
else:
|
||||
return None
|
||||
nxt = base
|
||||
while nxt <= after:
|
||||
nxt = advance(nxt)
|
||||
guard = 0
|
||||
while self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
nxt = advance(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
def due_tasks(self, tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
||||
"""Tasks that should start now: Scheduled + schedule enabled +
|
||||
run_at due."""
|
||||
due = []
|
||||
for t in tasks:
|
||||
if t.get("status") != "scheduled":
|
||||
continue
|
||||
sched = t.get("schedule", {})
|
||||
if not sched.get("enabled"):
|
||||
continue
|
||||
run_at = _parse_run_at(sched.get("run_at"))
|
||||
if run_at is not None and run_at <= now:
|
||||
due.append(t)
|
||||
return due
|
||||
|
||||
|
||||
__all__ = ["ScheduleCalculator"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Domain entities for tool risk classification and lookup (EPIC R05)."""
|
||||
|
||||
from .tool_descriptor import ToolCapability, ToolDescriptor
|
||||
from .tool_registry import (
|
||||
BUILT_IN_CAPABILITIES,
|
||||
UNKNOWN_SOURCE_CAPABILITIES,
|
||||
ToolRegistry,
|
||||
default_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ToolCapability",
|
||||
"ToolDescriptor",
|
||||
"ToolRegistry",
|
||||
"BUILT_IN_CAPABILITIES",
|
||||
"UNKNOWN_SOURCE_CAPABILITIES",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""ToolCapability / ToolDescriptor - the risk-tagged catalogue entry for one
|
||||
tool the agent loop can call (R05-T01).
|
||||
|
||||
Today a tool is just a name inside ``core/tools.py::TOOL_SPECS`` (a
|
||||
``providers.base.ToolSpec`` — name/description/JSON-schema parameters, with
|
||||
no notion of risk) plus a hand-written membership test wherever gating is
|
||||
needed: ``core/tools.py::WRITE_TOOLS``, ``core/code_agent.py``'s
|
||||
``WRITE_TOOLS | MS365_WRITE_TOOLS``, and ``core/chat_agent.py``'s literal
|
||||
``name in ("run_command", "install_package")``. Three call sites, three
|
||||
independently-maintained lists, and a new tool (or an MCP/connector tool,
|
||||
which has no list membership at all - see ``core/mcp_client.py``) is gated
|
||||
only if someone remembers to add it everywhere.
|
||||
|
||||
``ToolDescriptor`` makes the risk an attribute of the tool itself, declared
|
||||
once, so ``application/conversations/tool_policy_gateway.py`` (R05-T03) can
|
||||
decide ALLOW/CONFIRM/DENY from data instead of a growing set of literal
|
||||
tuples.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O. ``to_spec``/``from_spec`` are
|
||||
the only place this module touches something outside domain/, and that
|
||||
something (``providers.base.ToolSpec``) is itself a plain dataclass with no
|
||||
further dependencies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Flag, auto
|
||||
from typing import Any, Dict
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
|
||||
class ToolCapability(Flag):
|
||||
"""What calling a tool can do to the machine or the network.
|
||||
|
||||
A ``Flag`` (not a plain ``Enum``) because a single tool can combine risks
|
||||
- ``install_package`` writes to the environment, runs pip as a
|
||||
subprocess, AND needs network access. Composing three separate booleans
|
||||
per call site is exactly the duplication this type replaces.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
READ = auto()
|
||||
WRITE = auto()
|
||||
EXECUTE = auto()
|
||||
NETWORK = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolDescriptor:
|
||||
"""An immutable description of one callable tool.
|
||||
|
||||
Attributes:
|
||||
name: the identifier the model calls (``ToolSpec.name``).
|
||||
description: shown to the model, unchanged from ``ToolSpec``.
|
||||
parameters: JSON-Schema object for the call's arguments.
|
||||
capabilities: the risk this tool carries - see :class:`ToolCapability`.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any] = field(default_factory=dict)
|
||||
capabilities: ToolCapability = ToolCapability.NONE
|
||||
|
||||
def has(self, capability: ToolCapability) -> bool:
|
||||
"""True when this tool carries (any bit of) ``capability``."""
|
||||
return bool(self.capabilities & capability)
|
||||
|
||||
def to_spec(self) -> ToolSpec:
|
||||
"""Project back to the ``ToolSpec`` shape the model-facing catalogue
|
||||
and the provider call actually use - risk tagging is metadata the
|
||||
wire format has no room for."""
|
||||
return ToolSpec(name=self.name, description=self.description,
|
||||
parameters=self.parameters)
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: ToolSpec,
|
||||
capabilities: ToolCapability = ToolCapability.NONE) -> "ToolDescriptor":
|
||||
"""Wrap an existing ``ToolSpec`` (built-in, MCP, or connector) with a
|
||||
capability tag. The one place callers attach risk to a spec they did
|
||||
not author themselves."""
|
||||
return cls(name=spec.name, description=spec.description,
|
||||
parameters=spec.parameters, capabilities=capabilities)
|
||||
|
||||
|
||||
__all__ = ["ToolCapability", "ToolDescriptor"]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""ToolRegistry - the centralised catalogue every tool source registers into
|
||||
(R05-T01).
|
||||
|
||||
Built-in file/command/fetch tools (``core/tools.py``), MCP server tools
|
||||
(``core/mcp_client.py``) and unified connectors (``core/ext_connectors.py``)
|
||||
each produce their own ``List[ToolSpec]`` today, concatenated ad-hoc by
|
||||
``core/tools.py::combine_tool_sources``. None of that concatenation carries
|
||||
risk information, which is exactly why an MCP tool call reaches
|
||||
``core/chat_agent.py`` with no ``ToolDescriptor`` to consult and skips the
|
||||
permission gate entirely (the gap R05-T04 closes).
|
||||
|
||||
``ToolRegistry`` is the one place a :class:`~domain.tools.tool_descriptor.ToolDescriptor`
|
||||
is looked up by name, so a policy gateway - or anything else that needs to ask
|
||||
"what can this tool do" - has a single source of truth instead of re-deriving
|
||||
it from a spec list.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
from .tool_descriptor import ToolCapability, ToolDescriptor
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""An in-memory, name-keyed catalogue of :class:`ToolDescriptor`.
|
||||
|
||||
Deliberately mutable and unordered-by-name-only: a turn builds one
|
||||
registry from whichever tool sources it has (built-ins + whatever MCP
|
||||
servers/connectors are enabled), so re-registering the same name simply
|
||||
replaces the previous descriptor rather than raising - the same
|
||||
"last one wins" behaviour ``combine_tool_sources`` already has for
|
||||
duplicate tool names across sources.
|
||||
"""
|
||||
|
||||
def __init__(self, descriptors: Optional[Iterable[ToolDescriptor]] = None) -> None:
|
||||
"""Đăng ký sẵn một loạt tool. Đi qua ``register`` chứ không gán thẳng dict để
|
||||
mọi kiểm tra trùng tên đều chạy.
|
||||
"""
|
||||
self._by_name: Dict[str, ToolDescriptor] = {}
|
||||
for descriptor in descriptors or ():
|
||||
self.register(descriptor)
|
||||
|
||||
def register(self, descriptor: ToolDescriptor) -> None:
|
||||
"""Đăng ký (hoặc thay thế) một tool theo tên."""
|
||||
self._by_name[descriptor.name] = descriptor
|
||||
|
||||
def get(self, name: str) -> Optional[ToolDescriptor]:
|
||||
"""Mô tả của một tool; ``None`` nếu chưa đăng ký."""
|
||||
return self._by_name.get(name)
|
||||
|
||||
def all(self) -> List[ToolDescriptor]:
|
||||
"""Danh sách mọi tool đã đăng ký."""
|
||||
return list(self._by_name.values())
|
||||
|
||||
def specs(self) -> List[ToolSpec]:
|
||||
"""Every registered descriptor, projected back to ``ToolSpec`` - the
|
||||
shape the provider call and the model-facing catalogue need."""
|
||||
return [d.to_spec() for d in self._by_name.values()]
|
||||
|
||||
def capabilities_for(self, name: str) -> ToolCapability:
|
||||
"""The capability set for ``name``, or ``NONE`` for an unknown tool.
|
||||
|
||||
Returning ``NONE`` rather than raising lets a policy gateway treat an
|
||||
unregistered tool the same way as one with no declared risk - the
|
||||
gateway's DENY-on-unknown-name rule is a deliberate, separate check,
|
||||
not something this lookup should pre-empt.
|
||||
"""
|
||||
descriptor = self._by_name.get(name)
|
||||
return descriptor.capabilities if descriptor is not None else ToolCapability.NONE
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
"""``"tên" in registry`` — tra theo tên tool."""
|
||||
return name in self._by_name
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Số tool đã đăng ký."""
|
||||
return len(self._by_name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Default capability map for this app's built-in tools (core/tools.py).
|
||||
# Kept here, next to the registry, rather than inside core/tools.py itself -
|
||||
# core/ is the legacy engine layer being strangled, not where new domain facts
|
||||
# should accumulate.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_CAP = ToolCapability
|
||||
BUILT_IN_CAPABILITIES: Dict[str, ToolCapability] = {
|
||||
"read_file": _CAP.READ,
|
||||
"list_dir": _CAP.READ,
|
||||
"write_file": _CAP.WRITE,
|
||||
"edit_file": _CAP.WRITE,
|
||||
"run_command": _CAP.EXECUTE,
|
||||
"install_package": _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK,
|
||||
"fetch_url": _CAP.NETWORK,
|
||||
"jira_search": _CAP.NETWORK,
|
||||
"jira_get_issue": _CAP.NETWORK,
|
||||
# Advertised by every engine but has no filesystem/process/network effect
|
||||
# of its own - it only drives the Plan panel (see core/chat_agent.py).
|
||||
"update_plan": _CAP.NONE,
|
||||
"save_file": _CAP.WRITE,
|
||||
}
|
||||
|
||||
# Tools with no standard, self-declared risk metadata (every MCP server tool,
|
||||
# every unified connector) are tagged with this conservative default - see
|
||||
# R05-T04. Better to over-gate an unknown remote tool than to silently let it
|
||||
# through as READ-only.
|
||||
UNKNOWN_SOURCE_CAPABILITIES: ToolCapability = _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK
|
||||
|
||||
|
||||
def default_registry(specs: Iterable[ToolSpec]) -> ToolRegistry:
|
||||
"""Build a registry from ``core/tools.py``'s own ``TOOL_SPECS`` (plus
|
||||
``save_file``/``update_plan``, which the engines add separately), using
|
||||
:data:`BUILT_IN_CAPABILITIES`. A spec with no entry in that map falls back
|
||||
to :data:`UNKNOWN_SOURCE_CAPABILITIES` - the same conservative default
|
||||
applied to MCP/connector tools, so a built-in nobody has classified yet
|
||||
fails safe instead of silently ungated."""
|
||||
registry = ToolRegistry()
|
||||
for spec in specs:
|
||||
capability = BUILT_IN_CAPABILITIES.get(spec.name, UNKNOWN_SOURCE_CAPABILITIES)
|
||||
registry.register(ToolDescriptor.from_spec(spec, capability))
|
||||
return registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ToolRegistry",
|
||||
"BUILT_IN_CAPABILITIES",
|
||||
"UNKNOWN_SOURCE_CAPABILITIES",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""Thực thể và DTO thuần Python của phân hệ luồng công việc (Co4E)."""
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Bản ghi "một lần chạy flow" — DTO thuần Python cho tầng domain.
|
||||
|
||||
Bối cảnh: ``Co4ERunManager``/``RunHandle`` cũ (``core/co4e_run_manager.py``)
|
||||
trộn ba việc vào một ``QObject``: (1) dữ liệu một run cần nhớ để hiện Flow
|
||||
Status, (2) logic chạy job trên ``AgentWorker``/``QThread``, và (3) logic
|
||||
đọc/ghi lịch sử ra đĩa. Tách phần (1) ra thành ``RunRecord`` ở đây giúp nó độc
|
||||
lập với Qt và với việc đọc/ghi đĩa — đúng quy ước ``domain/__init__.py``: domain
|
||||
không được biết PySide6 tồn tại và không được chạm đĩa/mạng. Phần (2) và (3)
|
||||
chuyển sang ``application/workflows/co4e_workflow_service.py``
|
||||
(``Co4EWorkflowService``), nơi được phép import ``core/`` và làm việc với đĩa.
|
||||
|
||||
Vì sao trường ``wf`` là dict thô chứ không phải đối tượng ``Workflow``: lớp
|
||||
``Workflow`` sống ở ``core/co4e.py``, và việc dựng nó từ/thành dict
|
||||
(``workflow_to_dict``/``workflow_from_dict``) nằm trong module đó. Domain
|
||||
không được import ``cowork_local.core.*``, nên ``RunRecord`` giữ nguyên đúng
|
||||
hình dạng dữ liệu mà bản ghi lịch sử đã có sẵn trên đĩa hôm nay: một dict thô
|
||||
(kết quả ``workflow_to_dict``) hoặc ``None``. Việc quy đổi dict <-> đối tượng
|
||||
``Workflow`` là việc của tầng application, nơi được phép import ``core``.
|
||||
|
||||
Quirk giữ nguyên có chủ ý — đã bị "đóng đinh" bởi
|
||||
``tests/characterization/test_co4e_run_manager_behavior.py`` (quirk #1 và #7
|
||||
trong docstring đầu file đó, xem thêm ``RunHandle.to_record``/``from_record``
|
||||
gốc) — ĐỪNG "dọn" các chỗ này khi đọc code dưới đây, chúng trông như bug nhưng
|
||||
là hành vi đã được test khẳng định:
|
||||
* ``total`` âm bị ``max(0, total)`` kẹp về 0 ngay lúc khởi tạo, không giữ
|
||||
nguyên giá trị âm.
|
||||
* ``from_dict()`` đổi ``status == "running"`` đọc từ đĩa thành ``"stopped"``
|
||||
(lý do: app tắt giữa lúc một run đang "running" thì worker của nó đã mất
|
||||
theo, nên đọc lại không còn coi là đang chạy) — nhưng ``to_dict()`` vẫn ghi
|
||||
đúng ``"running"`` xuống đĩa tại thời điểm lưu. Đây là một round-trip
|
||||
*không đối xứng* có chủ ý.
|
||||
* ``from_dict({})``/``from_dict(None)`` mặc định ``status`` là ``"done"``
|
||||
(không phải ``"running"``) — nên KHÔNG bị nhánh phía trên đổi thành
|
||||
"stopped".
|
||||
|
||||
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
|
||||
------------------------------------------------------------
|
||||
Được nối khi: ``Co4EWorkflowService`` được nối dây — cùng điều kiện với seam ấy.
|
||||
Để dormant thì sao: DTO này và ``core/co4e_run_manager.py::RunHandle`` là
|
||||
hai bản của cùng một thứ; chỉ một bản được phép ở lại.
|
||||
|
||||
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
|
||||
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
|
||||
đọc theo — đừng sửa ngày để làm im lời nhắc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class RunRecord:
|
||||
"""DTO domain: trạng thái sống của một lần chạy flow, thuần dữ liệu.
|
||||
|
||||
Vai trò: đây là "danh từ" mà ``Co4EWorkflowService`` (application/) đọc/ghi
|
||||
và mà UI Flow Status hiển thị — không có hành vi chạy worker, không đọc/ghi
|
||||
đĩa. Nó ở tầng domain vì đây là quy tắc nghiệp vụ ổn định (hình dạng một
|
||||
lần chạy flow cần nhớ những gì) độc lập với Qt lẫn với cơ chế lưu trữ.
|
||||
"""
|
||||
|
||||
def __init__(self, run_id: str, wf_id: str, name: str, total: int,
|
||||
plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "",
|
||||
project_id: str = ""):
|
||||
"""Dựng một bản ghi run. ``total`` âm bị kẹp về 0 — quirk cố ý giữ nguyên từ
|
||||
``Co4ERunManager`` cũ, xem docstring đầu file.
|
||||
"""
|
||||
self.id = run_id
|
||||
self.wf_id = wf_id
|
||||
self.name = name
|
||||
self.project_id = project_id # workspace run này thuộc về (Flow Status lọc theo project)
|
||||
self.total = max(0, total) # quirk cố ý: total âm bị kẹp về 0, xem docstring đầu file
|
||||
self.done = 0
|
||||
self.status = "running" # running | done | error | stopped
|
||||
self.plan_mode = plan_mode
|
||||
self.manual = manual
|
||||
self.created_by = created_by
|
||||
self.created_at = created_at
|
||||
self.error = ""
|
||||
self.node_status: Dict[str, str] = {}
|
||||
self.wf: Optional[dict] = None # snapshot workflow dạng dict thô (xem docstring đầu file)
|
||||
self.out_dir = "" # thư mục workspace mà run này ghi file vào
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Run này còn đang chạy không."""
|
||||
return self.status == "running"
|
||||
|
||||
def progress_text(self) -> str:
|
||||
"""Chuỗi tiến độ để hiện lên bảng: "3/7" khi biết tổng số bước, còn không thì
|
||||
hiện trạng thái.
|
||||
"""
|
||||
return f"{self.done}/{self.total}" if self.total else self.status
|
||||
|
||||
# ---- (de)serialization --------------------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
"""Hình dạng bản ghi lịch sử trên đĩa.
|
||||
|
||||
PHẢI khớp đúng bộ khoá mà ``RunHandle.to_record()`` gốc
|
||||
(``core/co4e_run_manager.py``) đang ghi hôm nay — file JSON lịch sử cũ
|
||||
và mới dùng chung một định dạng trong lúc cả hai lớp còn chạy song
|
||||
song (bản cũ chưa bị xoá).
|
||||
"""
|
||||
return {
|
||||
"id": self.id, "wf_id": self.wf_id, "name": self.name,
|
||||
"total": self.total, "done": self.done, "status": self.status,
|
||||
"plan_mode": self.plan_mode, "manual": self.manual,
|
||||
"created_by": self.created_by, "created_at": self.created_at,
|
||||
"error": self.error, "node_status": dict(self.node_status),
|
||||
"wf": self.wf, "out_dir": self.out_dir, "project_id": self.project_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, rec: dict) -> "RunRecord":
|
||||
"""Dựng lại một run từ dict đọc ở file lịch sử.
|
||||
|
||||
Mọi trường đều có mặc định và được ép kiểu: file lịch sử là dữ liệu cũ có
|
||||
thể thiếu trường mà bản mới đã thêm.
|
||||
"""
|
||||
rec = dict(rec or {})
|
||||
r = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")),
|
||||
rec.get("name", ""), int(rec.get("total", 0) or 0),
|
||||
bool(rec.get("plan_mode")), bool(rec.get("manual")),
|
||||
created_by=rec.get("created_by", ""), created_at=rec.get("created_at", ""))
|
||||
r.done = int(rec.get("done", 0) or 0)
|
||||
r.status = rec.get("status", "done")
|
||||
# quirk cố ý (xem docstring đầu file): round-trip không đối xứng —
|
||||
# "running" đọc lại từ đĩa luôn bị chốt thành "stopped".
|
||||
if r.status == "running":
|
||||
r.status = "stopped"
|
||||
r.error = rec.get("error", "")
|
||||
r.node_status = dict(rec.get("node_status") or {})
|
||||
r.out_dir = rec.get("out_dir", "")
|
||||
r.project_id = rec.get("project_id", "")
|
||||
# Giữ nguyên dict thô -- KHONG parse thanh doi tuong Workflow o day (do
|
||||
# la viec cua tang application, xem docstring dau file).
|
||||
r.wf = rec.get("wf")
|
||||
return r
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities for workspace/project isolation (EPIC R06)."""
|
||||
|
||||
from .workspace_session import WorkspaceSession
|
||||
|
||||
__all__ = ["WorkspaceSession"]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""WorkspaceSession - an immutable snapshot of which project a turn belongs
|
||||
to and where it may touch the filesystem (R06-T01).
|
||||
|
||||
``state.py::AppContext.active_project_id`` is a single mutable field read by
|
||||
every background worker thread. ``ui/workspace_tab.py::_load_current`` writes
|
||||
it (and the related ``config._project_history_dir``) on the UI thread the
|
||||
moment the user switches projects - while a turn already running on a
|
||||
worker thread may read either field mid-switch and end up acting on the
|
||||
OTHER project's workspace/history for the rest of its run (the race
|
||||
R06-T04 fixes).
|
||||
|
||||
The fix, same shape as R04's ``ConversationExecutionRequest``: capture the
|
||||
workspace facts a turn needs ONCE, on the thread that knows which project is
|
||||
selected, into one frozen object. Whatever the user does to the UI afterwards,
|
||||
the turn keeps using the workspace it was handed at submit time.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no network. It does touch ``Path`` (not
|
||||
plain strings, unlike ``ConversationExecutionRequest``) because its whole job
|
||||
is path-containment checking - a snapshot with no room to answer "is this
|
||||
path mine" would not replace what ``ToolContext.resolve`` currently does
|
||||
inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceSession:
|
||||
"""Everything a turn needs to know about ITS workspace, fixed at the
|
||||
moment it was submitted.
|
||||
|
||||
Attributes:
|
||||
project_id: the project this turn belongs to (``""`` when no project
|
||||
is selected - e.g. the Code tab, which has no project concept).
|
||||
workspace_root: the project's sandbox root (``Project.workspace_dir()``).
|
||||
sandbox_dir: the ``.scratch`` subtree inside ``workspace_root`` used for
|
||||
generator/helper scripts, never a final deliverable (see
|
||||
``infrastructure/filesystem/file_tools.py::_flatten_rel``).
|
||||
allowed_paths: every root a tool call may read/write under. Almost
|
||||
always just ``(workspace_root,)``; a project with a custom
|
||||
``output_dir`` outside the managed workspace tree still resolves
|
||||
to exactly one root - the tuple exists so a future caller (e.g. a
|
||||
step scoped to a shared input folder) can widen it without a
|
||||
shape change.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
workspace_root: Path
|
||||
sandbox_dir: Path
|
||||
allowed_paths: Tuple[Path, ...] = field(default_factory=tuple)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Không khai đường dẫn cho phép thì mặc định đúng một đường: thư mục gốc của
|
||||
phiên. Để rỗng nghĩa là KHÔNG cho phép gì cả, không phải cho phép tất.
|
||||
"""
|
||||
if not self.allowed_paths:
|
||||
object.__setattr__(self, "allowed_paths", (self.workspace_root,))
|
||||
|
||||
@classmethod
|
||||
def from_project(cls, project) -> "WorkspaceSession":
|
||||
"""Build a session from a ``core.projects.Project``. ``project`` is
|
||||
typed loosely (not imported) so this module has no dependency on
|
||||
``core/`` - the caller (``core/projects.py`` itself, or
|
||||
``application/conversations``) already has the Project in hand."""
|
||||
root = Path(project.workspace_dir())
|
||||
return cls(project_id=project.project_id, workspace_root=root,
|
||||
sandbox_dir=root / ".scratch", allowed_paths=(root,))
|
||||
|
||||
@classmethod
|
||||
def unscoped(cls, workspace_root: Path) -> "WorkspaceSession":
|
||||
"""A session for callers with no project concept (e.g. the Code tab,
|
||||
which sandboxes to a plain folder rather than a ``Project``)."""
|
||||
root = Path(workspace_root)
|
||||
return cls(project_id="", workspace_root=root, sandbox_dir=root / ".scratch")
|
||||
|
||||
def is_allowed(self, path: Path) -> bool:
|
||||
"""True when ``path`` resolves inside one of :attr:`allowed_paths`.
|
||||
|
||||
Same containment rule as ``ToolContext.resolve`` (an exact root match
|
||||
or a real descendant), but side-effect-free: it reports the answer
|
||||
instead of raising, so a caller (``FileWorkspaceService``, R06-T05)
|
||||
can decide what "not allowed" means for its own UI instead of
|
||||
catching a ``ToolError``.
|
||||
"""
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
except OSError:
|
||||
return False
|
||||
for allowed in self.allowed_paths:
|
||||
root = Path(allowed).resolve()
|
||||
if resolved == root or root in resolved.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["WorkspaceSession"]
|
||||
Reference in New Issue
Block a user