Compare commits
18
Commits
develop
...
9d6a7be31b
@@ -39,3 +39,32 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python -m pytest tests -q
|
run: python -m pytest tests -q
|
||||||
|
|
||||||
|
# --- CASAN Verification Gate -------------------------------------
|
||||||
|
# Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để
|
||||||
|
# biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng.
|
||||||
|
#
|
||||||
|
# Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3
|
||||||
|
# (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để
|
||||||
|
# thêm cổng không làm đỏ CI của hai team kia.
|
||||||
|
|
||||||
|
- name: "CASAN Check 1 — không có credential lộ (Team Gamma)"
|
||||||
|
run: |
|
||||||
|
python scripts/audit_security.py --self-test
|
||||||
|
python scripts/audit_security.py
|
||||||
|
|
||||||
|
- name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)"
|
||||||
|
run: |
|
||||||
|
if [ -f scripts/check_loc.py ]; then
|
||||||
|
python scripts/check_loc.py
|
||||||
|
else
|
||||||
|
echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)"
|
||||||
|
run: |
|
||||||
|
if [ -f scripts/check_imports.py ]; then
|
||||||
|
python scripts/check_imports.py
|
||||||
|
else
|
||||||
|
echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua."
|
||||||
|
fi
|
||||||
|
|||||||
+11
-5
@@ -28,7 +28,10 @@ bower_components/
|
|||||||
.env.preview
|
.env.preview
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
secrets/
|
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
|
||||||
|
# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải
|
||||||
|
# bí mật) khỏi repo suốt 21-22/08.
|
||||||
|
/secrets/
|
||||||
credentials.json
|
credentials.json
|
||||||
.npmrc
|
.npmrc
|
||||||
.yarnrc
|
.yarnrc
|
||||||
@@ -36,9 +39,11 @@ credentials.json
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Build & Distribution
|
# Build & Distribution
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
dist/
|
# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau,
|
||||||
build/
|
# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials).
|
||||||
out/
|
/dist/
|
||||||
|
/build/
|
||||||
|
/out/
|
||||||
.next/
|
.next/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
.output/
|
.output/
|
||||||
@@ -73,7 +78,8 @@ desktop.ini
|
|||||||
# Logs & Debug
|
# Logs & Debug
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
*.log
|
*.log
|
||||||
logs/
|
# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may.
|
||||||
|
/logs/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""adapters/ — Adapter riêng cho Qt (clock, thread, timer).
|
||||||
|
|
||||||
|
Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy
|
||||||
|
bất kỳ script nào từ thư mục gốc repo (``python tools/...``,
|
||||||
|
``python scripts/...``) thì ``platform/`` **che khuất module ``platform``
|
||||||
|
của thư viện chuẩn**, và ``import keyring`` chết ngay với
|
||||||
|
``AttributeError: module 'platform' has no attribute 'system'``.
|
||||||
|
Repo có 26 script chạy đúng kiểu đó.
|
||||||
|
|
||||||
|
Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao
|
||||||
|
giờ chạy python từ thư mục gốc".
|
||||||
|
"""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application conversations package: turn lifecycle orchestration and agent execution."""
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""The turn lifecycle, once, in pure Python (R04-T03).
|
||||||
|
|
||||||
|
Extracted from ``core/chat_agent.py::run_cowork``, whose 260-line body mixed the
|
||||||
|
lifecycle (compose the prompt, call the model, dispatch tools, respect the step
|
||||||
|
ceiling, tidy the sandbox) with the concrete machinery that does each of those
|
||||||
|
things. The lifecycle is the part with rules worth testing — and the part that
|
||||||
|
was untestable, because reaching it meant standing up a Qt widget and a worker
|
||||||
|
thread.
|
||||||
|
|
||||||
|
Here it is a plain object driven through the seams in :mod:`turn_runtime`, so a
|
||||||
|
test states a rule ("the guard runs before the model", "a rejected command never
|
||||||
|
executes") in three lines. ``core/chat_agent.py`` keeps its signature and
|
||||||
|
delegates, and the presentation layer keeps receiving the same events via the
|
||||||
|
legacy codec, so nothing downstream had to change with it.
|
||||||
|
|
||||||
|
Behavioural contract: this is a faithful port, not an improvement pass. Where
|
||||||
|
the original had a quirk (the step-ceiling note only merges into the answer when
|
||||||
|
the last message is the assistant's), the quirk is preserved and commented —
|
||||||
|
changing what a user sees belongs in its own change, not smuggled into a move.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from ...domain.agents.agent_event import (
|
||||||
|
AssistantMessageCompletedEvent,
|
||||||
|
ErrorEvent,
|
||||||
|
OutputsAddedEvent,
|
||||||
|
OutputsRemovedEvent,
|
||||||
|
PlanStep,
|
||||||
|
PlanUpdatedEvent,
|
||||||
|
ReasoningChunkEvent,
|
||||||
|
TextChunkEvent,
|
||||||
|
ToolCallFinishedEvent,
|
||||||
|
ToolCallStartedEvent,
|
||||||
|
ToolOutputChunkEvent,
|
||||||
|
)
|
||||||
|
from ...domain.agents.agent_result import AgentResult
|
||||||
|
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
|
||||||
|
from .turn_runtime import (
|
||||||
|
BUDGET_NOTE_TEMPLATE,
|
||||||
|
GATED_TOOLS,
|
||||||
|
PLAN_TOOL,
|
||||||
|
REASONING_ONLY_NOTE,
|
||||||
|
REJECTED_OUTPUT,
|
||||||
|
AttachmentReader,
|
||||||
|
CancelFn,
|
||||||
|
CommandGuard,
|
||||||
|
ContextCompactor,
|
||||||
|
EventSink,
|
||||||
|
ModelCallPort,
|
||||||
|
PermissionRequest,
|
||||||
|
PromptGuard,
|
||||||
|
PromptPreparer,
|
||||||
|
ToolRuntimePort,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("cowork_local.application.conversations")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationApplicationService:
|
||||||
|
"""Runs one :class:`ConversationExecutionRequest` to completion."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: ModelCallPort,
|
||||||
|
tools: ToolRuntimePort,
|
||||||
|
*,
|
||||||
|
prepare_prompt: Optional[PromptPreparer] = None,
|
||||||
|
prompt_guard: Optional[PromptGuard] = None,
|
||||||
|
command_guard: Optional[CommandGuard] = None,
|
||||||
|
compact: Optional[ContextCompactor] = None,
|
||||||
|
permission_request: Optional[PermissionRequest] = None,
|
||||||
|
attachment_reader: Optional[AttachmentReader] = None,
|
||||||
|
) -> None:
|
||||||
|
self._model = model
|
||||||
|
self._tools = tools
|
||||||
|
# Every hook is optional so the service degrades to a plain chat turn.
|
||||||
|
# That is not only a test convenience: a headless caller legitimately has
|
||||||
|
# no guards (``security_config=None`` today) and no permission dialog.
|
||||||
|
self._prepare_prompt = prepare_prompt
|
||||||
|
self._prompt_guard = prompt_guard
|
||||||
|
self._command_guard = command_guard
|
||||||
|
self._compact = compact
|
||||||
|
self._permission_request = permission_request
|
||||||
|
self._attachment_reader = attachment_reader
|
||||||
|
|
||||||
|
# -- public API ------------------------------------------------------ #
|
||||||
|
def execute(self, request: ConversationExecutionRequest, sink: EventSink,
|
||||||
|
cancel: Optional[CancelFn] = None,
|
||||||
|
messages: Optional[List[Dict[str, Any]]] = None) -> AgentResult:
|
||||||
|
"""Run the turn, streaming events to ``sink``, and report the outcome.
|
||||||
|
|
||||||
|
``messages``, when given, is a working list the caller already built —
|
||||||
|
it MUST already end with this turn's user message, and the service
|
||||||
|
appends into that very object instead of composing its own. The Cowork
|
||||||
|
widget needs this: it hands out the same list to
|
||||||
|
``_reattach_running_turn``, which replays the steps done so far while the
|
||||||
|
worker is still appending, and to ``_finalize_turn``, which slices it by
|
||||||
|
the pre-turn snapshot length. A private list would break both silently.
|
||||||
|
Passing ``None`` (every headless caller) lets the service compose the
|
||||||
|
list from the request, which is the mode the rest of this class assumes.
|
||||||
|
|
||||||
|
Raises whatever the runtime raises (a blocked prompt, a dead gateway):
|
||||||
|
the caller already has a failure path for that — ``AgentWorker.failed``
|
||||||
|
in the UI, the artifact writer in Schedule Task — and swallowing the
|
||||||
|
exception here would silently turn a failed turn into an empty answer.
|
||||||
|
An :class:`ErrorEvent` is emitted first so subscribers see the failure
|
||||||
|
on the same stream as everything else.
|
||||||
|
"""
|
||||||
|
cancel = cancel or (lambda: False)
|
||||||
|
|
||||||
|
# -- pre-flight. Runs BEFORE the output snapshot, so a turn refused here
|
||||||
|
# leaves the output folder completely untouched (tidying is not a
|
||||||
|
# read-only operation — see ToolRuntimePort.finalize).
|
||||||
|
try:
|
||||||
|
# The caller's list is used by reference on purpose (see above); only
|
||||||
|
# the self-composed path may build a fresh one.
|
||||||
|
working = messages if messages is not None else self._compose_messages(request)
|
||||||
|
tools = list(self._tools.specs(request.allowed_tools))
|
||||||
|
if self._prepare_prompt is not None:
|
||||||
|
self._prepare_prompt(working, tuple(getattr(t, "name", "") for t in tools))
|
||||||
|
if request.enforce_rules and self._prompt_guard is not None:
|
||||||
|
self._prompt_guard(working)
|
||||||
|
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
|
||||||
|
sink(ErrorEvent(message=str(exc)))
|
||||||
|
raise
|
||||||
|
|
||||||
|
before = self._tools.snapshot()
|
||||||
|
steps_used = 0
|
||||||
|
plan_steps: Tuple[PlanStep, ...] = ()
|
||||||
|
completed_naturally = False
|
||||||
|
try:
|
||||||
|
for _ in range(request.effective_max_steps):
|
||||||
|
if cancel():
|
||||||
|
break
|
||||||
|
# Auto-compress when nearing the model's context budget; a no-op
|
||||||
|
# when off or when the conversation is still short.
|
||||||
|
if self._compact is not None:
|
||||||
|
self._compact(working, cancel)
|
||||||
|
|
||||||
|
assistant = self._model.call(
|
||||||
|
working, tools,
|
||||||
|
on_text=lambda piece: sink(TextChunkEvent(delta=piece)),
|
||||||
|
on_reasoning=lambda piece: sink(ReasoningChunkEvent(delta=piece)),
|
||||||
|
cancel=cancel,
|
||||||
|
)
|
||||||
|
working.append(assistant)
|
||||||
|
steps_used += 1
|
||||||
|
tool_calls = assistant.get("tool_calls") or []
|
||||||
|
|
||||||
|
if not tool_calls and not (assistant.get("content") or "").strip():
|
||||||
|
# Written into the message, not just emitted, so the stored
|
||||||
|
# conversation never ends on a blank assistant turn.
|
||||||
|
assistant["content"] = REASONING_ONLY_NOTE
|
||||||
|
sink(TextChunkEvent(delta=REASONING_ONLY_NOTE))
|
||||||
|
sink(AssistantMessageCompletedEvent(content=assistant.get("content", "")))
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
completed_naturally = True
|
||||||
|
break
|
||||||
|
|
||||||
|
for call in tool_calls:
|
||||||
|
if cancel():
|
||||||
|
break
|
||||||
|
tool_message, steps = self._dispatch(request, call, sink, cancel)
|
||||||
|
working.append(tool_message)
|
||||||
|
if steps is not None:
|
||||||
|
plan_steps = steps
|
||||||
|
|
||||||
|
if not completed_naturally and not cancel():
|
||||||
|
self._announce_budget_exhausted(request, working, sink)
|
||||||
|
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
|
||||||
|
sink(ErrorEvent(message=str(exc)))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Always tidy: the sandbox and generator scripts must not survive a
|
||||||
|
# turn that stopped abruptly. Runs on success, cancel and failure.
|
||||||
|
self._finalize_outputs(before, sink, cancelled=cancel())
|
||||||
|
|
||||||
|
result = AgentResult(
|
||||||
|
messages=working, steps_used=steps_used, cancelled=cancel(),
|
||||||
|
budget_exhausted=not completed_naturally and not cancel(),
|
||||||
|
plan_steps=plan_steps,
|
||||||
|
)
|
||||||
|
sink(result.to_turn_completed_event())
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- internals ------------------------------------------------------- #
|
||||||
|
def _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]:
|
||||||
|
"""History snapshot plus this turn's user message.
|
||||||
|
|
||||||
|
The attachment text is read HERE rather than when the request was built,
|
||||||
|
because extraction is slow enough to freeze the UI thread; the request
|
||||||
|
deliberately carries paths only.
|
||||||
|
"""
|
||||||
|
body = request.prompt
|
||||||
|
if self._attachment_reader is not None:
|
||||||
|
body = self._attachment_reader(request.prompt, request.attachments)
|
||||||
|
messages = [dict(m) for m in request.messages]
|
||||||
|
messages.append({"role": "user", "content": request.user_content(body)})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _dispatch(self, request: ConversationExecutionRequest, call: Dict[str, Any],
|
||||||
|
sink: EventSink, cancel: CancelFn
|
||||||
|
) -> Tuple[Dict[str, Any], Optional[Tuple[PlanStep, ...]]]:
|
||||||
|
"""Run one tool call.
|
||||||
|
|
||||||
|
Returns ``(tool_message, plan_steps)`` — the message to append to the
|
||||||
|
conversation, and the new checklist when this call was the plan tool
|
||||||
|
(``None`` otherwise, so the caller can tell "no change" from "empty
|
||||||
|
plan").
|
||||||
|
"""
|
||||||
|
call_id = str(call.get("id", ""))
|
||||||
|
name = str(call.get("name", ""))
|
||||||
|
args = call.get("arguments") or {}
|
||||||
|
|
||||||
|
# The plan tool is invisible in the transcript: it updates the Plan panel
|
||||||
|
# and nothing else, so it skips preview, guard and gate entirely.
|
||||||
|
if name == PLAN_TOOL:
|
||||||
|
outcome = self._tools.execute(name, args, on_output=None, cancel=cancel)
|
||||||
|
steps = tuple(outcome.get("plan_steps") or ())
|
||||||
|
sink(PlanUpdatedEvent(steps=steps))
|
||||||
|
return self._tool_message(call_id, name, outcome.get("output", "")), steps
|
||||||
|
|
||||||
|
# Announce first: the user sees the code/command about to run before the
|
||||||
|
# guard or the approval dialog interrupts them, which is the whole point
|
||||||
|
# of showing the step CLI-style.
|
||||||
|
preview = self._tools.preview(name, args)
|
||||||
|
sink(ToolCallStartedEvent(call_id=call_id, name=name, arguments=dict(args),
|
||||||
|
preview=preview))
|
||||||
|
|
||||||
|
if request.enforce_rules and self._command_guard is not None:
|
||||||
|
self._command_guard(name, args)
|
||||||
|
|
||||||
|
if not self._approved(request, name, args, preview, sink, call_id):
|
||||||
|
return self._tool_message(call_id, name, REJECTED_OUTPUT), None
|
||||||
|
|
||||||
|
outcome = self._tools.execute(
|
||||||
|
name, args,
|
||||||
|
on_output=lambda piece: sink(ToolOutputChunkEvent(
|
||||||
|
call_id=call_id, name=name, delta=piece)),
|
||||||
|
cancel=cancel,
|
||||||
|
)
|
||||||
|
sink(ToolCallFinishedEvent(
|
||||||
|
call_id=call_id, name=name, ok=bool(outcome.get("ok", False)),
|
||||||
|
output=str(outcome.get("output", "")), path=str(outcome.get("path", "") or ""),
|
||||||
|
produced=outcome.get("produced") or (),
|
||||||
|
))
|
||||||
|
return self._tool_message(call_id, name, outcome.get("output", "")), None
|
||||||
|
|
||||||
|
def _approved(self, request: ConversationExecutionRequest, name: str,
|
||||||
|
args: Dict[str, Any], preview: Any, sink: EventSink,
|
||||||
|
call_id: str) -> bool:
|
||||||
|
"""Whether this call may run.
|
||||||
|
|
||||||
|
Only command-shaped tools are gated, and only when the workspace asked
|
||||||
|
to confirm them: file writes stay inside the turn's own sandbox, so
|
||||||
|
prompting for those would be noise. A rejection is reported as a failed
|
||||||
|
tool result — the model needs to read back that it was refused, or it
|
||||||
|
will simply try the same call again.
|
||||||
|
"""
|
||||||
|
if not request.requires_permission_gate or name not in GATED_TOOLS:
|
||||||
|
return True
|
||||||
|
if self._permission_request is None:
|
||||||
|
# Confirm mode with nobody to ask: refusing is the safe direction,
|
||||||
|
# since auto-running is exactly what confirm mode exists to prevent.
|
||||||
|
logger.warning("turn: confirm mode without a permission callback — refusing %r", name)
|
||||||
|
approved = False
|
||||||
|
else:
|
||||||
|
approved = bool(self._permission_request({
|
||||||
|
"name": name, "args": args,
|
||||||
|
"preview": preview.to_dict() if preview is not None else {},
|
||||||
|
}))
|
||||||
|
if not approved:
|
||||||
|
sink(ToolCallFinishedEvent(call_id=call_id, name=name, ok=False,
|
||||||
|
output=REJECTED_OUTPUT))
|
||||||
|
return approved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_message(call_id: str, name: str, output: Any) -> Dict[str, Any]:
|
||||||
|
"""The canonical ``role: tool`` message the model reads back."""
|
||||||
|
return {"role": "tool", "tool_call_id": call_id, "name": name,
|
||||||
|
"content": str(output or "")}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _announce_budget_exhausted(request: ConversationExecutionRequest,
|
||||||
|
messages: List[Dict[str, Any]], sink: EventSink) -> None:
|
||||||
|
"""Report being cut off by the step ceiling.
|
||||||
|
|
||||||
|
The note always reaches the transcript. It is merged into the stored
|
||||||
|
answer only when the last message is the assistant's — which, when the
|
||||||
|
ceiling is hit, it never is (the turn ends on a tool result). The branch
|
||||||
|
is kept because it is what the current runtime does, and because it is
|
||||||
|
the correct behaviour the day a caller ends the loop differently.
|
||||||
|
"""
|
||||||
|
note = BUDGET_NOTE_TEMPLATE.format(steps=request.effective_max_steps)
|
||||||
|
sink(TextChunkEvent(delta=note))
|
||||||
|
if messages and messages[-1].get("role") == "assistant":
|
||||||
|
messages[-1]["content"] = (messages[-1].get("content") or "") + note
|
||||||
|
|
||||||
|
def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None:
|
||||||
|
"""Tidy the output folder and report what moved.
|
||||||
|
|
||||||
|
Failures are logged, never raised: this runs in a ``finally``, so an
|
||||||
|
exception here would replace the turn's real error (or its success) with
|
||||||
|
a housekeeping one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
removed, added = self._tools.finalize(before, cancelled=cancelled)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("turn: tidying the output folder failed")
|
||||||
|
return
|
||||||
|
if removed:
|
||||||
|
sink(OutputsRemovedEvent(paths=tuple(removed)))
|
||||||
|
if added:
|
||||||
|
sink(OutputsAddedEvent(paths=tuple(added)))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ConversationApplicationService"]
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03).
|
||||||
|
|
||||||
|
The service is written against the narrow seams in :mod:`turn_runtime` so it can
|
||||||
|
be tested with plain fakes. This module supplies the real implementations — the
|
||||||
|
provider call with its recovery pass, the tool/sandbox runtime, the security
|
||||||
|
guards, context compaction — and is therefore the ONLY file in
|
||||||
|
``application/conversations/`` that knows ``core/*`` exists. Same shape (and
|
||||||
|
same reason) as ``application/model_routing/core_routing_adapter.py`` in R03.
|
||||||
|
|
||||||
|
Every ``core`` import is deferred into a method body: importing the tool runtime
|
||||||
|
pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely
|
||||||
|
*builds* a service must not pay for that.
|
||||||
|
|
||||||
|
Faithfulness notes — two places where this reproduces a quirk of the current
|
||||||
|
runtime rather than the behaviour one would design fresh. Both are marked
|
||||||
|
inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools
|
||||||
|
(not the advertised subset), and the ``tool_result`` path falls back to the
|
||||||
|
call's own ``path`` argument resolved against the workdir.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from ...domain.agents.agent_event import PlanStep, ToolPreview
|
||||||
|
from .conversation_application_service import ConversationApplicationService
|
||||||
|
from .turn_runtime import PLAN_TOOL, EventSink
|
||||||
|
|
||||||
|
# Legacy emit: the dict-based callback every current caller already owns.
|
||||||
|
LegacyEmit = Callable[[Dict[str, Any]], None]
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_event_sink(emit: LegacyEmit) -> EventSink:
|
||||||
|
"""Adapt a typed :class:`EventSink` onto the legacy dict ``emit``.
|
||||||
|
|
||||||
|
This is what lets R04 land without touching the presentation layer: the
|
||||||
|
service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps
|
||||||
|
receiving exactly the dicts it already dispatches on. Deleted in R08 once
|
||||||
|
the widget consumes events directly.
|
||||||
|
"""
|
||||||
|
return lambda event: emit(event.to_legacy_dict())
|
||||||
|
|
||||||
|
|
||||||
|
class CoreModelCall:
|
||||||
|
""":class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``.
|
||||||
|
|
||||||
|
Not ``provider.chat`` directly: the recovery wrapper adds the one bounded
|
||||||
|
retry that hides a dropped connection or a momentarily unreachable gateway,
|
||||||
|
and losing it would be a visible regression on flaky corporate networks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, provider: Any) -> None:
|
||||||
|
self._provider = provider
|
||||||
|
|
||||||
|
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
|
||||||
|
from ...core.code_agent import _call_provider_with_recovery
|
||||||
|
|
||||||
|
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
|
||||||
|
cancel, on_reasoning)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreToolRuntime:
|
||||||
|
""":class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools."""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: Path, *, title: str = "",
|
||||||
|
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
|
||||||
|
security_config: Any = None, agent_role: str = "") -> None:
|
||||||
|
self._output_dir = Path(output_dir)
|
||||||
|
self._title = title
|
||||||
|
self._extra_tools = list(extra_tools or ())
|
||||||
|
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
|
||||||
|
# The connector executor MCP/REST tools are routed to; None when the
|
||||||
|
# turn has no connectors enabled.
|
||||||
|
self._extra_executor = extra_executor
|
||||||
|
self._security_config = security_config
|
||||||
|
self._agent_role = agent_role
|
||||||
|
self._ctx: Any = None # built on first use (see _tool_context)
|
||||||
|
|
||||||
|
# -- the configured extra tools, for the system-prompt hints ---------- #
|
||||||
|
@property
|
||||||
|
def extra_names(self) -> frozenset:
|
||||||
|
return frozenset(self._extra_names)
|
||||||
|
|
||||||
|
def _tool_context(self):
|
||||||
|
"""The sandboxed ``ToolContext`` every built-in tool call runs inside.
|
||||||
|
|
||||||
|
Built once per turn and cached: it carries the resource limits and the
|
||||||
|
network policy, so re-deriving it mid-turn could let a Settings change
|
||||||
|
take effect halfway through work already in flight.
|
||||||
|
"""
|
||||||
|
if self._ctx is None:
|
||||||
|
from ...core import agent_security
|
||||||
|
from ...core.tools import ToolContext
|
||||||
|
|
||||||
|
limits, block_network = agent_security.sandbox_settings(self._security_config)
|
||||||
|
self._ctx = ToolContext(
|
||||||
|
self._output_dir, flatten_writes=True, # keep every file in the Output root
|
||||||
|
resource_limits=limits, block_network=block_network,
|
||||||
|
allow_url_fetch=agent_security.url_fetch_allowed(self._security_config),
|
||||||
|
jira=(self._security_config.data.get("jira") if self._security_config else None),
|
||||||
|
)
|
||||||
|
return self._ctx
|
||||||
|
|
||||||
|
# -- ToolRuntimePort -------------------------------------------------- #
|
||||||
|
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]:
|
||||||
|
"""Advertised tools: Cowork's own two, the enabled built-ins, then MCP.
|
||||||
|
|
||||||
|
``allowed_tools`` restricts the list so a read-only step literally cannot
|
||||||
|
write. ``update_plan`` and the connector tools always survive the filter:
|
||||||
|
the plan tool has no side effects, and connectors are opted into
|
||||||
|
explicitly rather than governed by the built-in capability scope.
|
||||||
|
"""
|
||||||
|
from ...core.chat_agent import SAVE_FILE_SPEC
|
||||||
|
from ...core.plan import UPDATE_PLAN_SPEC
|
||||||
|
from ...core.tools import enabled_tool_specs
|
||||||
|
|
||||||
|
specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC]
|
||||||
|
+ list(enabled_tool_specs(self._security_config))
|
||||||
|
+ self._extra_tools)
|
||||||
|
if allowed_tools is None:
|
||||||
|
return specs
|
||||||
|
allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names
|
||||||
|
return [t for t in specs if getattr(t, "name", "") in allow]
|
||||||
|
|
||||||
|
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
|
||||||
|
"""What the user sees before the call runs."""
|
||||||
|
# A connector call has no local diff to show, so it renders as the plain
|
||||||
|
# argument dump the runtime already used.
|
||||||
|
if name in self._extra_names:
|
||||||
|
return ToolPreview(kind="info", title=name, text=str(args))
|
||||||
|
if name == "save_file":
|
||||||
|
return self._save_file_preview(args)
|
||||||
|
from ...core.tools import describe_action
|
||||||
|
|
||||||
|
raw = describe_action(self._tool_context(), name, args)
|
||||||
|
return ToolPreview.from_dict(raw)
|
||||||
|
|
||||||
|
def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview:
|
||||||
|
"""A before/after diff for the file the agent is about to write.
|
||||||
|
|
||||||
|
A brand-new file renders all-green (before is empty); an overwrite shows
|
||||||
|
the real change, so saving a file reads like editing one.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
from ...core.chat_agent import _structure_summary, _titled_filename
|
||||||
|
|
||||||
|
fname = _titled_filename(self._title, args.get("filename", "output.txt"))
|
||||||
|
content = str(args.get("content", ""))
|
||||||
|
summary = _structure_summary(fname, content)
|
||||||
|
old = ""
|
||||||
|
existing = self._output_dir / fname
|
||||||
|
if existing.exists():
|
||||||
|
try:
|
||||||
|
old = existing.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
pass # unreadable existing file: show it as a fresh write
|
||||||
|
diff = "".join(difflib.unified_diff(
|
||||||
|
old.splitlines(keepends=True), content.splitlines(keepends=True),
|
||||||
|
fromfile=f"a/{fname}", tofile=f"b/{fname}",
|
||||||
|
)) or content[:4000]
|
||||||
|
return ToolPreview(kind="diff", title=f"Save {fname}",
|
||||||
|
text=f"{summary}\n\n{diff[:4000]}")
|
||||||
|
|
||||||
|
def execute(self, name: str, args: Dict[str, Any], on_output=None,
|
||||||
|
cancel=None) -> Dict[str, Any]:
|
||||||
|
"""Run one tool call and return the runtime's result mapping."""
|
||||||
|
if name == PLAN_TOOL:
|
||||||
|
return self._execute_plan(args)
|
||||||
|
if name in self._extra_names and self._extra_executor is not None:
|
||||||
|
# Connector results carry no local file, so no path/produced keys —
|
||||||
|
# matching what the runtime reports for an MCP call today.
|
||||||
|
result = self._extra_executor(name, args) or {}
|
||||||
|
return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")}
|
||||||
|
if name == "save_file":
|
||||||
|
from ...core.chat_agent import _do_save_file
|
||||||
|
|
||||||
|
return dict(_do_save_file(self._output_dir, self._title, args))
|
||||||
|
|
||||||
|
from ...core.tools import execute_tool
|
||||||
|
|
||||||
|
ctx = self._tool_context()
|
||||||
|
result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output,
|
||||||
|
agent_role=self._agent_role))
|
||||||
|
# Quirk preserved: a tool that wrote the file named in its OWN arguments
|
||||||
|
# (write_file/edit_file) does not report a path, so the runtime derives
|
||||||
|
# one from the argument. Dropping this would empty the Output list.
|
||||||
|
if not result.get("path") and isinstance(args, dict) and args.get("path"):
|
||||||
|
result["path"] = str(ctx.workdir / str(args["path"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Apply an ``update_plan`` call: validate the steps and audit them.
|
||||||
|
|
||||||
|
Produces no file and no chat bubble; the service turns the returned
|
||||||
|
steps into a single plan event.
|
||||||
|
"""
|
||||||
|
from ...core import agent_roles, audit_log
|
||||||
|
from ...core.plan import normalize_plan_steps
|
||||||
|
|
||||||
|
steps = normalize_plan_steps(args.get("steps"))
|
||||||
|
audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)",
|
||||||
|
agent_role=agent_roles.PLANNER)
|
||||||
|
return {"ok": True, "output": "Plan updated.",
|
||||||
|
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
|
||||||
|
|
||||||
|
def snapshot(self) -> Any:
|
||||||
|
from ...core.tools import _snapshot
|
||||||
|
|
||||||
|
return _snapshot(self._output_dir)
|
||||||
|
|
||||||
|
def finalize(self, before: Any, cancelled: bool = False
|
||||||
|
) -> Tuple[List[str], List[str]]:
|
||||||
|
"""Drop the scratch sandbox and flatten deliverables into the root.
|
||||||
|
|
||||||
|
Returns ``(gone, arrived)``: a file that MOVED counts as both, because
|
||||||
|
the Output list keys entries by path and must drop the old one.
|
||||||
|
"""
|
||||||
|
from ...core.chat_agent import _cleanup_cowork_intermediates
|
||||||
|
|
||||||
|
removed, moved = _cleanup_cowork_intermediates(self._output_dir, before,
|
||||||
|
cancelled=cancelled)
|
||||||
|
gone = list(removed) + [old for old, _new in moved]
|
||||||
|
arrived = [new for _old, new in moved]
|
||||||
|
return gone, arrived
|
||||||
|
|
||||||
|
|
||||||
|
def build_cowork_conversation_service(
|
||||||
|
provider: Any,
|
||||||
|
output_dir: Path,
|
||||||
|
emit: LegacyEmit,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
project_context: str = "",
|
||||||
|
extra_tools: Optional[Sequence[Any]] = None,
|
||||||
|
extra_executor=None,
|
||||||
|
security_config: Any = None,
|
||||||
|
gate: Any = None,
|
||||||
|
agent_role: str = "",
|
||||||
|
) -> ConversationApplicationService:
|
||||||
|
"""A service wired to the real runtime, ready to execute a Cowork turn.
|
||||||
|
|
||||||
|
``emit`` is the legacy dict callback: the guards and the compactor publish
|
||||||
|
their own notices through it directly (exactly as they do now), while the
|
||||||
|
service's typed events reach it via :func:`legacy_event_sink`.
|
||||||
|
|
||||||
|
``gate`` present means the workspace asked to confirm commands; pass the
|
||||||
|
request with ``gate_mode="confirm"`` so the two agree. A gate of ``None``
|
||||||
|
keeps the pre-existing auto-run behaviour.
|
||||||
|
"""
|
||||||
|
from ...core import agent_roles
|
||||||
|
|
||||||
|
tools = CoreToolRuntime(
|
||||||
|
output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor,
|
||||||
|
security_config=security_config, agent_role=agent_role or agent_roles.COWORK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None:
|
||||||
|
"""Insert the system prompt, then fold in skills, rules and project text.
|
||||||
|
|
||||||
|
``advertised`` is unused on purpose: the runtime decides the MS365
|
||||||
|
paragraph from the CONFIGURED connector tools, not from the subset a
|
||||||
|
capability scope left advertised. Changing that changes the prompt the
|
||||||
|
model sees, so it stays as-is here and belongs to R05's tool-policy work.
|
||||||
|
"""
|
||||||
|
from ...core.chat_agent import (
|
||||||
|
COWORK_TOOL_PROMPT,
|
||||||
|
OPENDATALOADER_PDF_PROMPT,
|
||||||
|
_apply_project_context,
|
||||||
|
_apply_security_rules,
|
||||||
|
_apply_skills,
|
||||||
|
)
|
||||||
|
from ...core.deps import _can_pip
|
||||||
|
from ...core.java_runtime import find_java
|
||||||
|
from ...core.security_rules import load_rules
|
||||||
|
from ...core.skills import active_skills_text
|
||||||
|
|
||||||
|
if not messages or messages[0].get("role") != "system":
|
||||||
|
system = COWORK_TOOL_PROMPT
|
||||||
|
if any(n.startswith("ms365_") for n in tools.extra_names):
|
||||||
|
system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* "
|
||||||
|
"tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, "
|
||||||
|
"via the built-in MS365 MCP server). Use them whenever the request "
|
||||||
|
"involves that data — don't say you can't access it.")
|
||||||
|
if find_java() is not None and _can_pip():
|
||||||
|
# Only advertise the Java-backed PDF extractor when BOTH the JVM
|
||||||
|
# and pip are available, so the agent is never steered into a
|
||||||
|
# command that cannot work on this machine.
|
||||||
|
system += "\n\n" + OPENDATALOADER_PDF_PROMPT
|
||||||
|
messages.insert(0, {"role": "system", "content": system})
|
||||||
|
_apply_skills(messages, active_skills_text())
|
||||||
|
_apply_security_rules(messages, load_rules())
|
||||||
|
_apply_project_context(messages, project_context)
|
||||||
|
|
||||||
|
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
|
||||||
|
from ...core import agent_security
|
||||||
|
|
||||||
|
agent_security.enforce_prompt(provider, messages, security_config, emit)
|
||||||
|
|
||||||
|
def command_guard(name: str, args: Dict[str, Any]) -> None:
|
||||||
|
from ...core import agent_security
|
||||||
|
|
||||||
|
agent_security.enforce_command(provider, name, args, security_config, emit)
|
||||||
|
|
||||||
|
def compact(messages: List[Dict[str, Any]], cancel) -> None:
|
||||||
|
from ...core import context_budget
|
||||||
|
|
||||||
|
context_budget.maybe_compact(provider, messages, security_config,
|
||||||
|
emit=emit, cancel=cancel)
|
||||||
|
|
||||||
|
return ConversationApplicationService(
|
||||||
|
CoreModelCall(provider), tools,
|
||||||
|
prepare_prompt=prepare_prompt,
|
||||||
|
prompt_guard=prompt_guard,
|
||||||
|
command_guard=command_guard,
|
||||||
|
compact=compact,
|
||||||
|
permission_request=(gate.request if gate is not None else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime",
|
||||||
|
"build_cowork_conversation_service",
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Turn the Cowork widget's captured state into a request (R04-T04).
|
||||||
|
|
||||||
|
``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI
|
||||||
|
thread and has to translate three of them before a turn can run: which message
|
||||||
|
is this turn's prompt, which messages are its history, and whether the workspace
|
||||||
|
wants commands confirmed. Those rules lived inline in the widget, where no test
|
||||||
|
could reach them — and each fails silently when wrong (a duplicated user message,
|
||||||
|
or a command that quietly stops asking for approval).
|
||||||
|
|
||||||
|
They live here instead, as the mapping step the migration map assigns to the
|
||||||
|
application layer. The widget keeps only what is genuinely widget-specific:
|
||||||
|
reading its own state and building the provider.
|
||||||
|
|
||||||
|
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python.
|
||||||
|
Everything arrives as a plain value, so this module never sees a widget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional, Sequence
|
||||||
|
|
||||||
|
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
|
||||||
|
|
||||||
|
|
||||||
|
def build_cowork_turn_request(
|
||||||
|
*,
|
||||||
|
turn_id: str,
|
||||||
|
session_id: str,
|
||||||
|
messages: Sequence[Dict[str, Any]],
|
||||||
|
surface: str = "cowork",
|
||||||
|
project_id: str = "",
|
||||||
|
title: str = "",
|
||||||
|
provider_id: str = "",
|
||||||
|
model: str = "",
|
||||||
|
instructions: str = "",
|
||||||
|
output_dir: Optional[Any] = None,
|
||||||
|
home_output_root: Optional[Any] = None,
|
||||||
|
confirm_commands: bool = False,
|
||||||
|
agent_role: str = "cowork",
|
||||||
|
) -> ConversationExecutionRequest:
|
||||||
|
"""Build one Cowork turn's immutable request.
|
||||||
|
|
||||||
|
``messages`` is the widget's working list, which ALREADY ends with this
|
||||||
|
turn's user message (the chat panel composes it — prefix, attachments,
|
||||||
|
session notes — before the job starts). So the prompt is that last message
|
||||||
|
and the history is everything before it. The request records both; the
|
||||||
|
service is handed the same working list and appends into it.
|
||||||
|
|
||||||
|
Keyword-only on purpose: a dozen positional strings in a call site is exactly
|
||||||
|
how a title ends up in the project-id slot.
|
||||||
|
"""
|
||||||
|
history = list(messages or ())
|
||||||
|
# ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no
|
||||||
|
# special branch: a turn with nothing in it yields an empty prompt instead of
|
||||||
|
# raising IndexError deep inside a worker thread.
|
||||||
|
last = history.pop() if history else {}
|
||||||
|
return ConversationExecutionRequest(
|
||||||
|
turn_id=turn_id,
|
||||||
|
session_id=session_id,
|
||||||
|
surface=surface,
|
||||||
|
project_id=project_id,
|
||||||
|
title=title,
|
||||||
|
prompt=str(last.get("content") or ""),
|
||||||
|
messages=history,
|
||||||
|
provider_id=provider_id,
|
||||||
|
model=model,
|
||||||
|
project_context=instructions,
|
||||||
|
output_dir=output_dir,
|
||||||
|
home_output_root=home_output_root,
|
||||||
|
# The workspace's Auto-run override (or the global setting) decides
|
||||||
|
# whether run_command/install_package must be approved first.
|
||||||
|
gate_mode="confirm" if confirm_commands else "auto",
|
||||||
|
agent_role=agent_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_cowork_turn_request"]
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""The seams :mod:`conversation_application_service` runs a turn through (R04-T03).
|
||||||
|
|
||||||
|
Two Protocols and six callables — chosen deliberately, not by reflex. The
|
||||||
|
refactor plan forbids giving every class an interface, so a contract exists here
|
||||||
|
only where there is both a real ``core/*`` implementation AND a test double:
|
||||||
|
|
||||||
|
* :class:`ModelCallPort` — one provider round-trip *including* the app's
|
||||||
|
existing context-overflow recovery, which is why the raw ``Provider.chat``
|
||||||
|
signature is not enough.
|
||||||
|
* :class:`ToolRuntimePort` — the tool + output-folder runtime, kept as one
|
||||||
|
cohesive object because every method operates on the same sandbox.
|
||||||
|
|
||||||
|
Everything else is a single function, so it is expressed as a callable type
|
||||||
|
rather than a class with one method (the same choice R03 made for
|
||||||
|
``ConfirmationCallback``). All of them are optional: a service built with none
|
||||||
|
of them still runs a plain chat turn, which is what keeps the unit tests short.
|
||||||
|
|
||||||
|
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
|
||||||
|
layer — pure Python. Nothing here imports PySide6, ``core.*``, ``providers.*``
|
||||||
|
or ``ui.*``; the concrete wiring lives in :mod:`core_runtime_adapter`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Protocol,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
runtime_checkable,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...domain.agents.agent_event import AgentEvent, ToolPreview
|
||||||
|
|
||||||
|
# The plan tool is special-cased by the loop: it drives the Plan panel and
|
||||||
|
# produces no chat bubble and no file. Named here so the check is not a bare
|
||||||
|
# string literal in the middle of the dispatch.
|
||||||
|
PLAN_TOOL = "update_plan"
|
||||||
|
|
||||||
|
# Tools that need approval before they run when the workspace is in confirm
|
||||||
|
# mode. R05 replaces this tuple with a real ``ToolPolicyGateway`` keyed on
|
||||||
|
# ToolCapability; until then it mirrors exactly what the runtime gates today.
|
||||||
|
GATED_TOOLS = ("run_command", "install_package")
|
||||||
|
|
||||||
|
# Shown when the user (or the workspace policy) rejects a proposed command. The
|
||||||
|
# exact string also becomes the tool message the model reads back, so it must
|
||||||
|
# stay stable.
|
||||||
|
REJECTED_OUTPUT = "Rejected by user."
|
||||||
|
|
||||||
|
# A reasoning model can answer with thinking only. The note is written into the
|
||||||
|
# assistant message itself, not merely emitted, so an unattended run does not
|
||||||
|
# read back an empty answer and report "(no output)".
|
||||||
|
REASONING_ONLY_NOTE = "*(model returned only its reasoning — try rephrasing)*"
|
||||||
|
|
||||||
|
# Emitted when the turn is stopped by its own safety ceiling rather than by the
|
||||||
|
# model finishing. Never silent: being cut off looks exactly like being done.
|
||||||
|
BUDGET_NOTE_TEMPLATE = (
|
||||||
|
"\n\n⚠️ Reached the {steps}-step safety limit before the task signalled "
|
||||||
|
"completion — stopping here. Re-run to continue if more work remains."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def combine_instructions(*blocks: Optional[str]) -> str:
|
||||||
|
"""Join the standing-instruction blocks of a turn, skipping the absent ones.
|
||||||
|
|
||||||
|
A turn's instructions arrive as several independent blocks — the project's
|
||||||
|
shared context, an Admin agent's persona, a skill's rules, the
|
||||||
|
"this runs unattended" reminder — and each caller was joining them inline
|
||||||
|
with its own ``f"{a}\\n\\n{b}" if a else b`` expression. Two call sites now
|
||||||
|
need the same rule (the Cowork widget in R04-T04 and the task runner in
|
||||||
|
R04-T05), which is the point at which it stops being an expression.
|
||||||
|
|
||||||
|
Whitespace-only blocks count as absent: they would otherwise open the system
|
||||||
|
prompt with a stray blank line.
|
||||||
|
"""
|
||||||
|
return "\n\n".join(b.strip() for b in blocks if b and b.strip())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Callables.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Receives every typed event the turn produces. The caller decides what that
|
||||||
|
# means — render it, forward it as a legacy dict, autosave on it.
|
||||||
|
EventSink = Callable[[AgentEvent], None]
|
||||||
|
|
||||||
|
# True once the user has asked to stop. Polled between steps and between tool
|
||||||
|
# calls, the same cadence the current runtime uses.
|
||||||
|
CancelFn = Callable[[], bool]
|
||||||
|
|
||||||
|
# ``(prompt, attachment_paths) -> body``. Runs on the worker thread because
|
||||||
|
# extracting a .docx may pip-install a parser or call LibreOffice.
|
||||||
|
AttachmentReader = Callable[[str, Tuple[str, ...]], str]
|
||||||
|
|
||||||
|
# ``(messages, advertised_tool_names) -> None`` — inserts the system prompt and
|
||||||
|
# folds in skills, security rules and project instructions, in place. It needs
|
||||||
|
# the tool names because the system prompt gains an MS365 paragraph only when
|
||||||
|
# ms365 tools are actually present.
|
||||||
|
PromptPreparer = Callable[[List[Dict[str, Any]], Tuple[str, ...]], None]
|
||||||
|
|
||||||
|
# Reviews the assembled request; raises to refuse the turn outright.
|
||||||
|
PromptGuard = Callable[[List[Dict[str, Any]]], None]
|
||||||
|
|
||||||
|
# Reviews one proposed tool call; raises to refuse it.
|
||||||
|
CommandGuard = Callable[[str, Dict[str, Any]], None]
|
||||||
|
|
||||||
|
# ``(messages, cancel) -> None``. Summarises old turns in place when the
|
||||||
|
# conversation nears the model's context budget; a no-op when compaction is off
|
||||||
|
# or the conversation is short. It takes the cancel signal because compacting
|
||||||
|
# calls the model itself, so Stop has to reach it too.
|
||||||
|
ContextCompactor = Callable[[List[Dict[str, Any]], "CancelFn"], None]
|
||||||
|
|
||||||
|
# ``(action) -> approved``. Blocks the worker thread while a human decides.
|
||||||
|
PermissionRequest = Callable[[Dict[str, Any]], bool]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Ports.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@runtime_checkable
|
||||||
|
class ModelCallPort(Protocol):
|
||||||
|
"""One call to the model, with the app's retry/recovery behaviour applied."""
|
||||||
|
|
||||||
|
def call(self, messages: List[Dict[str, Any]], tools: Sequence[Any],
|
||||||
|
on_text: Optional[Callable[[str], None]] = None,
|
||||||
|
on_reasoning: Optional[Callable[[str], None]] = None,
|
||||||
|
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
|
||||||
|
"""Return the canonical assistant message (content plus tool calls)."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ToolRuntimePort(Protocol):
|
||||||
|
"""The tools a turn may call, and the folder its files land in."""
|
||||||
|
|
||||||
|
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> Sequence[Any]:
|
||||||
|
"""Tool specs to advertise to the model, already filtered.
|
||||||
|
|
||||||
|
Returns opaque objects (the provider layer's ``ToolSpec``); the service
|
||||||
|
only ever reads ``.name`` off them, which is what keeps this layer free
|
||||||
|
of a provider import.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
|
||||||
|
"""Human-readable description of a call that is about to run."""
|
||||||
|
|
||||||
|
def execute(self, name: str, args: Dict[str, Any],
|
||||||
|
on_output: Optional[Callable[[str], None]] = None,
|
||||||
|
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
|
||||||
|
"""Run one tool call.
|
||||||
|
|
||||||
|
Returns the runtime's own result mapping: ``ok``, ``output``, optionally
|
||||||
|
``path``/``produced`` for files it created, and ``plan_steps`` for the
|
||||||
|
plan tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def snapshot(self) -> Any:
|
||||||
|
"""Opaque record of the output folder before the turn started."""
|
||||||
|
|
||||||
|
def finalize(self, before: Any, cancelled: bool = False
|
||||||
|
) -> Tuple[Sequence[str], Sequence[str]]:
|
||||||
|
"""Tidy the output folder; return ``(removed_paths, added_paths)``.
|
||||||
|
|
||||||
|
Not read-only — it deletes the scratch sandbox and flattens sub-folders —
|
||||||
|
so the service only calls it for a turn that actually started.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLAN_TOOL", "GATED_TOOLS", "REJECTED_OUTPUT", "REASONING_ONLY_NOTE",
|
||||||
|
"BUDGET_NOTE_TEMPLATE", "combine_instructions",
|
||||||
|
"EventSink", "CancelFn", "AttachmentReader", "PromptPreparer", "PromptGuard",
|
||||||
|
"CommandGuard", "ContextCompactor", "PermissionRequest",
|
||||||
|
"ModelCallPort", "ToolRuntimePort",
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Application model routing package: model route decisions and multi-provider balancing.
|
||||||
|
|
||||||
|
Public surface (R03-T03 — the single routing entry point every chat surface uses):
|
||||||
|
|
||||||
|
* :class:`RoutingApplicationService` — decides one turn's provider/model.
|
||||||
|
* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out.
|
||||||
|
* :class:`RoutingMode` — Off / Auto / Manual / Fallback.
|
||||||
|
* :func:`build_routing_application_service` — wires the service to a live
|
||||||
|
``AppContext`` (engine + per-workspace mode + confirm timeout).
|
||||||
|
|
||||||
|
Typical call site (see ``ui/chat_panel.py::_apply_routing``)::
|
||||||
|
|
||||||
|
service = build_routing_application_service(self.ctx)
|
||||||
|
outcome = service.resolve(
|
||||||
|
RoutingRequest(surface="cowork", prompt=text,
|
||||||
|
current_provider=provider, current_model=model),
|
||||||
|
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs
|
||||||
|
stay pure Python so the whole rule set is testable without Qt or the engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .core_routing_adapter import (
|
||||||
|
AppContextModeResolver,
|
||||||
|
CoreRoutingEngine,
|
||||||
|
build_routing_application_service,
|
||||||
|
)
|
||||||
|
from .routing_application_service import (
|
||||||
|
ConfirmationCallback,
|
||||||
|
ModeResolver,
|
||||||
|
RoutingApplicationService,
|
||||||
|
RoutingDecisionPort,
|
||||||
|
)
|
||||||
|
from .routing_models import (
|
||||||
|
RouteEvaluation,
|
||||||
|
RoutingMode,
|
||||||
|
RoutingOutcome,
|
||||||
|
RoutingRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppContextModeResolver",
|
||||||
|
"ConfirmationCallback",
|
||||||
|
"CoreRoutingEngine",
|
||||||
|
"ModeResolver",
|
||||||
|
"RouteEvaluation",
|
||||||
|
"RoutingApplicationService",
|
||||||
|
"RoutingDecisionPort",
|
||||||
|
"RoutingMode",
|
||||||
|
"RoutingOutcome",
|
||||||
|
"RoutingRequest",
|
||||||
|
"build_routing_application_service",
|
||||||
|
]
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Adapters that plug the existing routing engine into the application service.
|
||||||
|
|
||||||
|
:mod:`routing_application_service` is written against two narrow ports so it can
|
||||||
|
be unit-tested with plain fakes. This module supplies the real implementations —
|
||||||
|
the assessment/scoring engine in ``core/routing`` and the per-workspace mode
|
||||||
|
lookup on ``AppContext`` — and is therefore the ONLY file in
|
||||||
|
``application/model_routing/`` that knows those concrete types exist.
|
||||||
|
|
||||||
|
All engine imports are deferred into method bodies. Importing the routing stack
|
||||||
|
pulls in Pydantic models and the on-disk assessment store, and the UI must be
|
||||||
|
able to import this module during startup without paying that cost (the same
|
||||||
|
lazy-wiring reason ``state.py::AppContext.routing`` gives).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .routing_application_service import RoutingApplicationService
|
||||||
|
from .routing_models import RouteEvaluation, RoutingMode, RoutingRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger("cowork_local.application.model_routing")
|
||||||
|
|
||||||
|
|
||||||
|
class CoreRoutingEngine:
|
||||||
|
""":class:`RoutingDecisionPort` backed by ``core/routing/service.py``.
|
||||||
|
|
||||||
|
Translates in both directions: application DTOs in, and the engine's
|
||||||
|
``RouteResult``/``SwitchDecision``/``TaskType`` flattened back out into a
|
||||||
|
:class:`RouteEvaluation`, so no ``core.routing`` type ever escapes into the
|
||||||
|
application service or the UI call sites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, routing_service: Any) -> None:
|
||||||
|
self._routing_service = routing_service
|
||||||
|
|
||||||
|
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||||
|
"""Rank candidates for this turn and report the engine's verdict."""
|
||||||
|
from ...core.routing.models import TaskType, candidate_key
|
||||||
|
|
||||||
|
result = self._routing_service.route(
|
||||||
|
request.surface,
|
||||||
|
request.prompt,
|
||||||
|
request.current_provider,
|
||||||
|
request.current_model,
|
||||||
|
# The engine only knows off/auto/manual; FALLBACK was already mapped
|
||||||
|
# to AUTO upstream so the value handed over here is always valid.
|
||||||
|
mode_override=mode.value,
|
||||||
|
required_capabilities=list(request.required_capabilities) or None,
|
||||||
|
task_type=self._parse_task_type(request.task_type, TaskType),
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = result.decision
|
||||||
|
target = result.target() # (provider, model_id) or None
|
||||||
|
current_key = (
|
||||||
|
candidate_key(request.current_provider, request.current_model)
|
||||||
|
if request.current_model
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return RouteEvaluation(
|
||||||
|
task_type=self._task_type_value(result.task_type),
|
||||||
|
should_switch=bool(result.should_switch),
|
||||||
|
target_provider=target[0] if target else None,
|
||||||
|
target_model=target[1] if target else None,
|
||||||
|
score_gain=float(getattr(decision, "score_gain", 0.0) or 0.0),
|
||||||
|
reason=str(getattr(decision, "reason", "") or ""),
|
||||||
|
current_is_usable=self._current_is_usable(result, current_key),
|
||||||
|
decision=decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- translation helpers --------------------------------------------- #
|
||||||
|
@staticmethod
|
||||||
|
def _parse_task_type(raw: Optional[str], task_type_enum) -> Optional[Any]:
|
||||||
|
"""Coerce a task-type string to the engine's enum.
|
||||||
|
|
||||||
|
``None`` (the common case) means "let the engine classify the prompt".
|
||||||
|
An unrecognised string is also downgraded to ``None`` rather than
|
||||||
|
raising, so a stale value in a saved workspace cannot break a turn.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, task_type_enum):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return task_type_enum(str(raw).strip().lower())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("routing: unknown task type %r — classifying from the prompt", raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _task_type_value(task_type: Any) -> str:
|
||||||
|
"""The plain string form of the engine's task type enum."""
|
||||||
|
return str(getattr(task_type, "value", task_type) or "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _current_is_usable(result: Any, current_key: str) -> bool:
|
||||||
|
"""Whether the currently selected model can still serve this task.
|
||||||
|
|
||||||
|
This is the signal FALLBACK mode acts on. A model is usable when the
|
||||||
|
ranking scored it above zero; ``rank_models`` already drops candidates
|
||||||
|
that are unavailable, lack a probe for this task type, or failed their
|
||||||
|
last probe, so "absent from the ranking" is precisely "cannot serve it".
|
||||||
|
|
||||||
|
With no ranking (routing off, or the engine's internal error path) or no
|
||||||
|
current model, we answer True: absence of evidence must not trigger a
|
||||||
|
surprise switch in a mode whose whole promise is not to surprise.
|
||||||
|
"""
|
||||||
|
ranking = getattr(result, "ranking", None)
|
||||||
|
if ranking is None or not current_key:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return float(ranking.score_of(current_key)) > 0.0
|
||||||
|
except Exception: # noqa: BLE001 — defensive: never fail a turn on telemetry-ish data
|
||||||
|
logger.debug("routing: could not score current model %r", current_key, exc_info=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AppContextModeResolver:
|
||||||
|
""":class:`ModeResolver` backed by the active workspace's settings.
|
||||||
|
|
||||||
|
Reads through ``AppContext.project_routing_mode``, which already layers the
|
||||||
|
workspace override on top of the global default — so per-workspace routing
|
||||||
|
modes keep working unchanged now that the mode lookup moved out of the
|
||||||
|
widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ctx: Any) -> None:
|
||||||
|
self._ctx = ctx
|
||||||
|
|
||||||
|
def mode_for(self, surface: str) -> RoutingMode:
|
||||||
|
"""Effective mode for ``surface`` in the active workspace."""
|
||||||
|
return RoutingMode.parse(self._ctx.project_routing_mode(surface))
|
||||||
|
|
||||||
|
|
||||||
|
def build_routing_application_service(ctx: Any) -> RoutingApplicationService:
|
||||||
|
"""The shared :class:`RoutingApplicationService` for this app context.
|
||||||
|
|
||||||
|
Cached on the context (like ``AppContext.routing()`` caches the engine) so
|
||||||
|
every surface talks to the same instance and a future stateful addition —
|
||||||
|
per-surface cool-down, switch history — is shared rather than duplicated per
|
||||||
|
widget. Falls back to a fresh instance if the context refuses attribute
|
||||||
|
assignment, which keeps tests using lightweight stand-ins working.
|
||||||
|
"""
|
||||||
|
cached = getattr(ctx, "_routing_app_service", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
service = RoutingApplicationService(
|
||||||
|
CoreRoutingEngine(ctx.routing()),
|
||||||
|
AppContextModeResolver(ctx),
|
||||||
|
# Read at call time: the user can change the confirm timeout in Settings
|
||||||
|
# between two turns and the next Manual dialog should honour it.
|
||||||
|
confirm_timeout_sec=lambda: float(
|
||||||
|
(ctx.config.routing or {}).get("confirm_timeout_sec", 60) or 60
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ctx._routing_app_service = service
|
||||||
|
except Exception: # noqa: BLE001 — read-only/slotted stand-ins stay supported
|
||||||
|
logger.debug("routing: could not cache the application service on the context", exc_info=True)
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppContextModeResolver",
|
||||||
|
"CoreRoutingEngine",
|
||||||
|
"build_routing_application_service",
|
||||||
|
]
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""The one place that decides how a turn is routed (R03-T03).
|
||||||
|
|
||||||
|
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and
|
||||||
|
``ui/folder_tab.py`` each carried their own copy of the same eight-step dance:
|
||||||
|
clear last turn's override → read the surface's mode → bail on "off" → call the
|
||||||
|
routing engine → check ``should_switch`` → resolve the target → show the Manual
|
||||||
|
confirm dialog → publish the override and a status line. Three copies meant
|
||||||
|
three chances to drift, and none of them could be tested without a Qt widget.
|
||||||
|
|
||||||
|
The dance now lives here, once, in pure Python:
|
||||||
|
|
||||||
|
* the routing engine is reached through :class:`RoutingDecisionPort`;
|
||||||
|
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`;
|
||||||
|
* the Manual-mode confirmation through a ``confirm`` callback supplied per call,
|
||||||
|
so the Qt dialog stays in the presentation layer where it belongs.
|
||||||
|
|
||||||
|
Every failure path degrades to "keep the current model": a routing problem must
|
||||||
|
never be the reason a user cannot send a message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from .routing_models import (
|
||||||
|
RouteEvaluation,
|
||||||
|
RoutingMode,
|
||||||
|
RoutingOutcome,
|
||||||
|
RoutingRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("cowork_local.application.model_routing")
|
||||||
|
|
||||||
|
# Asks the user to approve a Manual-mode switch. Receives the underlying
|
||||||
|
# decision object (for rendering) plus the timeout in seconds; returns True to
|
||||||
|
# approve. Supplied by the caller so this module never imports a UI toolkit.
|
||||||
|
ConfirmationCallback = Callable[[Any, float], bool]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RoutingDecisionPort(Protocol):
|
||||||
|
"""The routing engine, as this service needs it.
|
||||||
|
|
||||||
|
Narrowed to a single method on purpose: the concrete engine
|
||||||
|
(``core/routing/service.py::RoutingService``) exposes assessment,
|
||||||
|
persistence and scheduling too, none of which a turn-time decision needs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||||
|
"""Rank candidates for ``request`` and report whether to switch."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ModeResolver(Protocol):
|
||||||
|
"""Resolves the effective routing mode for a surface.
|
||||||
|
|
||||||
|
In the app this reads the active workspace's per-surface override with the
|
||||||
|
global default behind it (``AppContext.project_routing_mode``); in tests it
|
||||||
|
is a two-line stub.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def mode_for(self, surface: str) -> RoutingMode:
|
||||||
|
"""Effective mode for ``surface``."""
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingApplicationService:
|
||||||
|
"""Turn-time routing decisions for every chat surface."""
|
||||||
|
|
||||||
|
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when
|
||||||
|
# no timeout provider is wired, so a bare service is still usable in tests.
|
||||||
|
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
decision_port: RoutingDecisionPort,
|
||||||
|
mode_resolver: Optional[ModeResolver] = None,
|
||||||
|
*,
|
||||||
|
confirm_timeout_sec: Optional[Callable[[], float]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._decision_port = decision_port
|
||||||
|
self._mode_resolver = mode_resolver
|
||||||
|
# A callable rather than a number: the timeout lives in mutable config
|
||||||
|
# the user can change in Settings between two turns.
|
||||||
|
self._confirm_timeout_sec = confirm_timeout_sec
|
||||||
|
|
||||||
|
# -- public API ------------------------------------------------------ #
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
request: RoutingRequest,
|
||||||
|
confirm: Optional[ConfirmationCallback] = None,
|
||||||
|
) -> RoutingOutcome:
|
||||||
|
"""Decide this turn's provider/model.
|
||||||
|
|
||||||
|
Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None``
|
||||||
|
whenever the surface should keep its own selection. Never raises — an
|
||||||
|
unexpected failure is logged and reported as "keep current", because a
|
||||||
|
broken assessment store must not block chatting.
|
||||||
|
"""
|
||||||
|
mode = request.mode or self._resolve_mode(request.surface)
|
||||||
|
try:
|
||||||
|
return self._resolve_unguarded(request, mode, confirm)
|
||||||
|
except Exception: # noqa: BLE001 — routing must never break a turn
|
||||||
|
logger.exception("routing.resolve failed — keeping the current model")
|
||||||
|
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model")
|
||||||
|
|
||||||
|
def confirm_timeout(self) -> float:
|
||||||
|
"""Seconds to wait for a Manual-mode confirmation.
|
||||||
|
|
||||||
|
Falls back to the built-in default when the provider is missing or
|
||||||
|
returns something unusable, so a corrupted config value cannot produce a
|
||||||
|
zero-second dialog that instantly declines every switch.
|
||||||
|
"""
|
||||||
|
if self._confirm_timeout_sec is None:
|
||||||
|
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||||
|
try:
|
||||||
|
value = float(self._confirm_timeout_sec())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||||
|
return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||||
|
|
||||||
|
# -- internals ------------------------------------------------------- #
|
||||||
|
def _resolve_mode(self, surface: str) -> RoutingMode:
|
||||||
|
"""The surface's configured mode, defaulting to OFF when unresolvable —
|
||||||
|
routing stays opt-in, so "we don't know" must mean "don't switch"."""
|
||||||
|
if self._mode_resolver is None:
|
||||||
|
return RoutingMode.OFF
|
||||||
|
try:
|
||||||
|
return RoutingMode.parse(self._mode_resolver.mode_for(surface))
|
||||||
|
except Exception: # noqa: BLE001 — a config read must not break a turn
|
||||||
|
logger.exception("routing: could not resolve mode for surface %r", surface)
|
||||||
|
return RoutingMode.OFF
|
||||||
|
|
||||||
|
def _resolve_unguarded(
|
||||||
|
self,
|
||||||
|
request: RoutingRequest,
|
||||||
|
mode: RoutingMode,
|
||||||
|
confirm: Optional[ConfirmationCallback],
|
||||||
|
) -> RoutingOutcome:
|
||||||
|
"""The decision flow proper; :meth:`resolve` owns the safety net."""
|
||||||
|
# 1. Routing disabled, or nothing to classify -> keep the selection.
|
||||||
|
if mode is RoutingMode.OFF:
|
||||||
|
return RoutingOutcome.keep_current(mode, reason="routing off")
|
||||||
|
if not request.has_prompt:
|
||||||
|
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route")
|
||||||
|
|
||||||
|
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because
|
||||||
|
# it needs the same candidate list; only the accept/reject rule below
|
||||||
|
# differs, so the engine stays unaware of the extra mode.
|
||||||
|
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode
|
||||||
|
evaluation = self._decision_port.evaluate(request, engine_mode)
|
||||||
|
|
||||||
|
# 3. Apply the mode's own accept rule to the engine's verdict.
|
||||||
|
if mode is RoutingMode.FALLBACK:
|
||||||
|
accepted, reason = self._fallback_verdict(evaluation)
|
||||||
|
else:
|
||||||
|
accepted, reason = evaluation.should_switch, evaluation.reason
|
||||||
|
|
||||||
|
if not accepted or not evaluation.has_target:
|
||||||
|
return RoutingOutcome.keep_current(
|
||||||
|
mode,
|
||||||
|
reason=reason or evaluation.reason,
|
||||||
|
task_type=evaluation.task_type,
|
||||||
|
decision=evaluation.decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Manual mode asks first; a decline or a timeout keeps the current
|
||||||
|
# model (and is reported as such, so the surface can tell the two
|
||||||
|
# cases apart from "nothing better was found").
|
||||||
|
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm):
|
||||||
|
return RoutingOutcome.keep_current(
|
||||||
|
mode,
|
||||||
|
reason="switch declined by user or confirmation timed out",
|
||||||
|
task_type=evaluation.task_type,
|
||||||
|
declined=True,
|
||||||
|
decision=evaluation.decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Publish the override for THIS turn only. The provider falls back to
|
||||||
|
# the request's current provider when the engine named a model but no
|
||||||
|
# provider (same-provider switch).
|
||||||
|
return RoutingOutcome(
|
||||||
|
mode=mode,
|
||||||
|
switched=True,
|
||||||
|
provider=evaluation.target_provider or request.current_provider,
|
||||||
|
model=evaluation.target_model or "",
|
||||||
|
task_type=evaluation.task_type,
|
||||||
|
score_gain=evaluation.score_gain,
|
||||||
|
reason=reason or evaluation.reason,
|
||||||
|
decision=evaluation.decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
|
||||||
|
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
|
||||||
|
|
||||||
|
The user's pinned model wins as long as it can serve the turn, even when
|
||||||
|
a higher-scoring candidate exists — that is the whole point of the mode.
|
||||||
|
A switch happens only when the current model is not a usable candidate
|
||||||
|
(never assessed, marked unavailable, or its last probe failed) and the
|
||||||
|
engine has something to move to.
|
||||||
|
"""
|
||||||
|
if evaluation.current_is_usable:
|
||||||
|
return False, "fallback mode — current model is healthy, keeping it"
|
||||||
|
if not evaluation.has_target:
|
||||||
|
return False, "fallback mode — current model unusable and no replacement available"
|
||||||
|
return True, "fallback mode — current model unavailable, switching to the best alternative"
|
||||||
|
|
||||||
|
def _approved(
|
||||||
|
self,
|
||||||
|
evaluation: RouteEvaluation,
|
||||||
|
confirm: Optional[ConfirmationCallback],
|
||||||
|
) -> bool:
|
||||||
|
"""Run the Manual-mode confirmation callback.
|
||||||
|
|
||||||
|
No callback means no way to ask, and silently switching in Manual mode
|
||||||
|
would violate the mode's contract — so a missing callback is treated as
|
||||||
|
"not approved". A callback that raises is treated the same way, since a
|
||||||
|
broken dialog must not auto-approve a model change.
|
||||||
|
"""
|
||||||
|
if confirm is None:
|
||||||
|
logger.warning("routing: manual mode without a confirmation callback — keeping current model")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(confirm(evaluation.decision, self.confirm_timeout()))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("routing: confirmation callback failed — keeping current model")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConfirmationCallback",
|
||||||
|
"ModeResolver",
|
||||||
|
"RoutingApplicationService",
|
||||||
|
"RoutingDecisionPort",
|
||||||
|
]
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
|
||||||
|
|
||||||
|
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
|
||||||
|
now speak instead of each re-deriving routing state from raw config lookups and
|
||||||
|
``core/routing`` internals.
|
||||||
|
|
||||||
|
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
|
||||||
|
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
|
||||||
|
``core.routing`` either — the concrete routing engine is reached only through
|
||||||
|
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
|
||||||
|
testable with plain fakes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingMode(str, Enum):
|
||||||
|
"""The four routing behaviours a surface can be in (R03-T03).
|
||||||
|
|
||||||
|
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
|
||||||
|
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
|
||||||
|
deliberately NOT an optimisation mode: it keeps whatever model the user
|
||||||
|
chose and only re-routes when that model cannot serve the turn, which is the
|
||||||
|
behaviour a resilience-minded workspace wants (never surprise me, but never
|
||||||
|
leave me stuck either).
|
||||||
|
"""
|
||||||
|
|
||||||
|
OFF = "off"
|
||||||
|
AUTO = "auto"
|
||||||
|
MANUAL = "manual"
|
||||||
|
FALLBACK = "fallback"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
|
||||||
|
"""Best-effort coercion from config/UI strings.
|
||||||
|
|
||||||
|
Routing must never break a turn, so an unrecognised value degrades to
|
||||||
|
``default`` (``OFF`` unless told otherwise) instead of raising — the same
|
||||||
|
defensive posture ``config.routing_mode_for`` already takes.
|
||||||
|
"""
|
||||||
|
fallback = default if default is not None else cls.OFF
|
||||||
|
if isinstance(raw, cls):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return cls(str(raw or "").strip().lower())
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoutingRequest:
|
||||||
|
"""Everything needed to decide how ONE turn should be routed.
|
||||||
|
|
||||||
|
Frozen: the request is captured from live UI state (the selected model, the
|
||||||
|
typed prompt) and then handed to code that may run on a worker thread. An
|
||||||
|
immutable snapshot means the user changing the model picker mid-turn cannot
|
||||||
|
retroactively alter the decision that was already made — the same rationale
|
||||||
|
behind R04's ``ConversationExecutionRequest``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
surface: str # "cowork" | "co4e" | "ai_edit" | ...
|
||||||
|
prompt: str # the user's text; drives task classification
|
||||||
|
current_provider: str # provider the surface would use as-is
|
||||||
|
current_model: str = "" # model the surface would use ("" = provider default)
|
||||||
|
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
|
||||||
|
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
|
||||||
|
# turns are coding work, so it pins this and skips prompt classification.
|
||||||
|
task_type: Optional[str] = None
|
||||||
|
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_prompt(self) -> bool:
|
||||||
|
"""Whether there is anything to classify. An empty prompt cannot be
|
||||||
|
routed meaningfully, so every surface short-circuits on it."""
|
||||||
|
return bool((self.prompt or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouteEvaluation:
|
||||||
|
"""A routing engine's verdict, normalised away from ``core/routing`` types.
|
||||||
|
|
||||||
|
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
|
||||||
|
fields so the application service never touches Pydantic models or enums
|
||||||
|
owned by another layer. ``decision`` still carries the original object
|
||||||
|
because the Manual-mode confirm dialog renders its ``reason``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
task_type: str
|
||||||
|
should_switch: bool
|
||||||
|
target_provider: Optional[str] = None
|
||||||
|
target_model: Optional[str] = None
|
||||||
|
score_gain: float = 0.0
|
||||||
|
reason: str = ""
|
||||||
|
# False when the currently selected model is not a usable candidate for this
|
||||||
|
# task (unranked, unavailable, or failed its last probe) — the single signal
|
||||||
|
# FALLBACK mode acts on.
|
||||||
|
current_is_usable: bool = True
|
||||||
|
decision: Any = None # original SwitchDecision, for the UI dialog
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_target(self) -> bool:
|
||||||
|
"""A switch is only actionable when the engine named a model to move to."""
|
||||||
|
return bool(self.target_model or self.target_provider)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoutingOutcome:
|
||||||
|
"""What the calling surface should actually do for this turn.
|
||||||
|
|
||||||
|
A surface needs exactly three things from routing — "which provider/model do
|
||||||
|
I build?", "do I tell the user?" and "was I told to stand down?" — so those
|
||||||
|
are the fields here, and nothing else. ``provider``/``model`` are ``None``
|
||||||
|
when the surface should keep its own selection untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode: RoutingMode
|
||||||
|
switched: bool = False
|
||||||
|
provider: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
task_type: str = ""
|
||||||
|
score_gain: float = 0.0
|
||||||
|
reason: str = ""
|
||||||
|
# True when Manual mode proposed a switch and the user declined or the
|
||||||
|
# confirmation timed out. Distinct from "no switch proposed" so a surface
|
||||||
|
# can tell "routing had nothing to offer" from "the user said no".
|
||||||
|
declined: bool = False
|
||||||
|
decision: Any = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_notify(self) -> bool:
|
||||||
|
"""Whether the surface should post the "switched model" status bubble.
|
||||||
|
Only an executed switch is worth interrupting the transcript for."""
|
||||||
|
return self.switched
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def keep_current(
|
||||||
|
cls,
|
||||||
|
mode: RoutingMode,
|
||||||
|
*,
|
||||||
|
reason: str = "",
|
||||||
|
task_type: str = "",
|
||||||
|
declined: bool = False,
|
||||||
|
decision: Any = None,
|
||||||
|
) -> "RoutingOutcome":
|
||||||
|
"""The no-change outcome — the single constructor for every path that
|
||||||
|
leaves the surface's own model selection in place (routing off, empty
|
||||||
|
prompt, no better candidate, user declined, internal error)."""
|
||||||
|
return cls(
|
||||||
|
mode=mode, switched=False, provider=None, model=None,
|
||||||
|
task_type=task_type, reason=reason, declined=declined, decision=decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application monitoring package: Monitoring query service for audit and metrics."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application scheduling package: TaskApplicationService and AI task planning."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application settings package: Settings application service."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application workflows package: Co4E graph execution orchestration."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application workspaces package: File workspace and AI file editor services."""
|
||||||
@@ -555,19 +555,26 @@ class AppConfig:
|
|||||||
d["surface_modes"].setdefault(surface, "")
|
d["surface_modes"].setdefault(surface, "")
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def routing_mode_for(self, surface: str) -> str:
|
# The routing modes a surface may be in. "fallback" joined the set in
|
||||||
"""Effective Off/Auto/Manual mode for a chat surface.
|
# R03-T03 (keep the selected model; re-route only when it cannot serve the
|
||||||
|
# turn) — see application/model_routing/routing_models.py::RoutingMode,
|
||||||
|
# which is the authority on what each mode means.
|
||||||
|
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||||
|
|
||||||
A per-surface override ("auto"/"manual"/"off") wins; an empty override
|
def routing_mode_for(self, surface: str) -> str:
|
||||||
falls back to the global ``switch_mode``."""
|
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
|
||||||
|
|
||||||
|
A per-surface override wins; an empty override falls back to the global
|
||||||
|
``switch_mode``. Anything unrecognised degrades to "off" so routing
|
||||||
|
stays opt-in even with a hand-edited config."""
|
||||||
routing = self.routing
|
routing = self.routing
|
||||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||||
mode = override or routing.get("switch_mode", "off")
|
mode = override or routing.get("switch_mode", "off")
|
||||||
return mode if mode in ("off", "auto", "manual") else "off"
|
return mode if mode in self.ROUTING_MODES else "off"
|
||||||
|
|
||||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||||
"""Persist a chat surface's Off/Auto/Manual toggle selection."""
|
"""Persist a chat surface's routing toggle selection."""
|
||||||
mode = mode if mode in ("off", "auto", "manual") else "off"
|
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|||||||
+69
-19
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
|
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
|
||||||
|
|
||||||
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
|
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
|
||||||
|
files) — the same turn engine the interactive Cowork chat
|
||||||
|
runs on since R04-T05
|
||||||
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
|
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
|
||||||
- ``script`` → local subprocess with a timeout
|
- ``script`` → local subprocess with a timeout
|
||||||
- ``flow`` → the task's own simple step list, run sequentially, each
|
- ``flow`` → the task's own simple step list, run sequentially, each
|
||||||
@@ -162,6 +164,30 @@ _TIMEOUT_NOTICE_TMPL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_UNATTENDED_PREFIX = (
|
||||||
|
"This runs unattended (Schedule Task) — no one is watching live. Use "
|
||||||
|
"update_plan to track your steps and keep it accurate: mark a step "
|
||||||
|
"'error' (not silently skip it) if it genuinely can't be completed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unattended_prompt(prompt: str, *, skill_text: str = "",
|
||||||
|
agent_instructions: str = "") -> str:
|
||||||
|
"""Assemble the user message an unattended run sends.
|
||||||
|
|
||||||
|
The order is load-bearing and used to be encoded as three successive
|
||||||
|
rebindings of ``prompt``, each prepending its own block: the plan reminder
|
||||||
|
must lead (it is the instruction that keeps a run without a human watching
|
||||||
|
honest), then the chosen skill's rules, then the Admin agent's persona, and
|
||||||
|
the task's own words last. Routing it through ``combine_instructions`` keeps
|
||||||
|
that order in one readable expression and drops the absent blocks instead of
|
||||||
|
leaving blank lines behind.
|
||||||
|
"""
|
||||||
|
from ..application.conversations.turn_runtime import combine_instructions
|
||||||
|
|
||||||
|
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
|
||||||
|
|
||||||
|
|
||||||
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
|
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
|
||||||
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
|
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
|
||||||
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
|
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
|
||||||
@@ -218,36 +244,29 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
|||||||
# default, see state.build_provider_for). A legacy Admin-agent preset
|
# default, see state.build_provider_for). A legacy Admin-agent preset
|
||||||
# (task.admin_agent_id), if still set on an older task, keeps working and
|
# (task.admin_agent_id), if still set on an older task, keeps working and
|
||||||
# takes precedence — it pins the provider/model AND prepends instructions.
|
# takes precedence — it pins the provider/model AND prepends instructions.
|
||||||
|
agent_instructions = ""
|
||||||
if admin_agent is not None:
|
if admin_agent is not None:
|
||||||
from .admin_agents import build_agent_provider
|
from .admin_agents import build_agent_provider
|
||||||
|
|
||||||
provider = build_agent_provider(ctx, admin_agent)
|
provider = build_agent_provider(ctx, admin_agent)
|
||||||
agent_instructions = admin_agent.effective_prompt()
|
agent_instructions = admin_agent.effective_prompt()
|
||||||
if agent_instructions:
|
|
||||||
prompt = f"{agent_instructions}\n\n{prompt}"
|
|
||||||
elif provider_name or model:
|
elif provider_name or model:
|
||||||
# An explicit per-task provider/model override.
|
# An explicit per-task provider/model override.
|
||||||
provider = ctx.build_provider_for(provider_name or None, model or None)
|
provider = ctx.build_provider_for(provider_name or None, model or None)
|
||||||
else:
|
else:
|
||||||
# Neither overridden → the machine's own Settings default, exactly as before.
|
# Neither overridden → the machine's own Settings default, exactly as before.
|
||||||
provider = ctx.build_active_provider()
|
provider = ctx.build_active_provider()
|
||||||
# A chosen skill's instructions are prepended so this unattended run follows
|
# A chosen skill's instructions are applied so this unattended run follows
|
||||||
# them, mirroring how the interactive chat applies /skill.
|
# them, mirroring how the interactive chat applies /skill.
|
||||||
|
skill_text = ""
|
||||||
if skill_slug:
|
if skill_slug:
|
||||||
from .skills import skill_prefix_for
|
from .skills import skill_prefix_for
|
||||||
|
|
||||||
skill_text = skill_prefix_for(skill_slug)
|
skill_text = skill_prefix_for(skill_slug)
|
||||||
if skill_text:
|
# Assemble reminder + skill + persona + the task's own words in one place
|
||||||
prompt = f"{skill_text}\n\n{prompt}"
|
# (see _unattended_prompt for why that order matters).
|
||||||
# This is an UNATTENDED run (no human watching to catch a half-finished
|
prompt = _unattended_prompt(prompt, skill_text=skill_text,
|
||||||
# job) — push the agent to actually use the Plan checklist so completion
|
agent_instructions=agent_instructions)
|
||||||
# can be verified afterward, instead of just trusting "no exception".
|
|
||||||
prompt = (
|
|
||||||
"This runs unattended (Schedule Task) — no one is watching live. Use "
|
|
||||||
"update_plan to track your steps and keep it accurate: mark a step "
|
|
||||||
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
|
|
||||||
f"{prompt}"
|
|
||||||
)
|
|
||||||
messages = [{"role": "user", "content": prompt}]
|
messages = [{"role": "user", "content": prompt}]
|
||||||
session_id = new_session_id()
|
session_id = new_session_id()
|
||||||
project_id = project.project_id if project is not None else ""
|
project_id = project.project_id if project is not None else ""
|
||||||
@@ -273,10 +292,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
|||||||
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
|
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
|
||||||
try:
|
try:
|
||||||
if task_type == "cowork":
|
if task_type == "cowork":
|
||||||
from .chat_agent import run_cowork
|
# R04-T05: the unattended run shares the interactive turn engine
|
||||||
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
|
# instead of calling run_cowork itself, so there is exactly one place
|
||||||
security_config=ctx.config, agent_role=agent_roles.TASK,
|
# where a turn's lifecycle is defined. Everything unattended-specific
|
||||||
project_context=project_context)
|
# stays here (the plan reminder above, the History autosave in
|
||||||
|
# emit_and_autosave, the timeout notice below).
|
||||||
|
from ..application.conversations.core_runtime_adapter import (
|
||||||
|
build_cowork_conversation_service,
|
||||||
|
legacy_event_sink,
|
||||||
|
)
|
||||||
|
from ..domain.agents.conversation_execution_request import (
|
||||||
|
ConversationExecutionRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No extra_tools/extra_executor and no permission gate: a scheduled
|
||||||
|
# run gets no MCP connectors and nobody is there to approve a
|
||||||
|
# command, which is exactly what run_cowork was called with.
|
||||||
|
service = build_cowork_conversation_service(
|
||||||
|
provider, out_dir, emit_and_autosave, title=title,
|
||||||
|
project_context=project_context, security_config=ctx.config,
|
||||||
|
agent_role=agent_roles.TASK,
|
||||||
|
)
|
||||||
|
request = ConversationExecutionRequest(
|
||||||
|
# The artifact folder is named by the run id, which identifies
|
||||||
|
# this attempt in the audit log.
|
||||||
|
turn_id=out_dir.name or session_id, session_id=session_id,
|
||||||
|
surface="task", title=title, project_id=project_id,
|
||||||
|
prompt=prompt, output_dir=out_dir,
|
||||||
|
agent_role=agent_roles.TASK, unattended=True,
|
||||||
|
timeout_sec=timeout_sec,
|
||||||
|
)
|
||||||
|
# ``messages`` is handed over so the History autosave in
|
||||||
|
# emit_and_autosave (and the final save in the finally block below)
|
||||||
|
# keep reading the live conversation as it grows.
|
||||||
|
service.execute(request, legacy_event_sink(emit_and_autosave),
|
||||||
|
cancel=watched_cancel, messages=messages)
|
||||||
else:
|
else:
|
||||||
from .code_agent import run_code
|
from .code_agent import run_code
|
||||||
limits, block_network = agent_security.sandbox_settings(ctx.config)
|
limits, block_network = agent_security.sandbox_settings(ctx.config)
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ def set_context(source: str, label: str = "") -> None:
|
|||||||
_local.label = label
|
_local.label = label
|
||||||
|
|
||||||
|
|
||||||
|
def current_context() -> tuple:
|
||||||
|
"""The ``(source, label)`` currently tagged on THIS thread.
|
||||||
|
|
||||||
|
Public counterpart to :func:`set_context`, added for
|
||||||
|
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
|
||||||
|
attribute one event to a different surface must be able to save the
|
||||||
|
caller's context and put it back afterwards, instead of leaving the worker
|
||||||
|
thread permanently retagged.
|
||||||
|
"""
|
||||||
|
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
|
||||||
|
|
||||||
|
|
||||||
# ---- per-thread usage accumulator -----------------------------------------
|
# ---- per-thread usage accumulator -----------------------------------------
|
||||||
# A step/run that wants to know its OWN token/cost (not the all-time file total)
|
# A step/run that wants to know its OWN token/cost (not the all-time file total)
|
||||||
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
|
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# ADR-001: 4-Tier Clean Architecture for Desktop Local Application
|
||||||
|
|
||||||
|
* **Status**: ACCEPTED / ENFORCED
|
||||||
|
* **Date**: 2026-08-21
|
||||||
|
* **Deciders**: Team Duy (Tech Lead & AI Runtime), Team Nam (Governance & Automation), Team Hoa (Workspace & Scheduling)
|
||||||
|
* **Target Project**: Cowork Local (Cowork-Local BamBOO)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context and Problem Statement
|
||||||
|
|
||||||
|
Cowork Local is a desktop application written in Python using PySide6 (Qt) and designed for local-first execution.
|
||||||
|
Historically, the codebase suffered from architectural coupling across layers:
|
||||||
|
1. **God-Widget Problem**: Monolithic UI widgets (e.g., `ui/chat_panel.py` >1,800 LOC, `ui/co4e_tab.py` >1,400 LOC) mixed UI rendering, network I/O, business rules, filesystem operations, and background worker lifecycle.
|
||||||
|
2. **Untestable Business Logic**: Core algorithms (model routing, conversation turn management, schedule calculation) were tightly coupled to `PySide6` widgets or `QTimer`, making unit testing in headless CI environments impossible without a graphical display server.
|
||||||
|
3. **Circular Dependencies & Global State Leaks**: Uncontrolled module imports (`model_pricing.py` ↔ `usage_tracker.py`, `agent_security.py` ↔ `agent_security_alert.py`) and mutable global state (`state.py::AppContext.active_project_id`) caused race conditions in background task runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision: 4-Tier Clean Architecture
|
||||||
|
|
||||||
|
We enforce a strict **4-Tier Clean Architecture** based on the Dependency Inversion Principle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PRESENTATION │
|
||||||
|
│ (PySide6 Widgets, Dialogs, Qt Signals/Slots, View Models) │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ depends on
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ APPLICATION │
|
||||||
|
│ (Use Case Services, Turn Orchestrators, Route Dispatchers) │
|
||||||
|
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ depends on
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ DOMAIN & RUNTIME CORE │
|
||||||
|
│ (Entities, Value Objects, Domain Events, Tool Descriptors) │
|
||||||
|
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
|
||||||
|
└──────────────────────────────▲──────────────────────────────┘
|
||||||
|
│ implemented by
|
||||||
|
┌──────────────────────────────┴──────────────────────────────┐
|
||||||
|
│ INFRASTRUCTURE │
|
||||||
|
│ (LLM Providers, Keyring Secrets, Atomic Persistence, MCP) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layer Definitions and Responsibilities
|
||||||
|
|
||||||
|
### Tier 1: Presentation Layer (`presentation/`)
|
||||||
|
* **Responsibilities**: UI component layout, user event capture, progress display, visual animations, confirmation dialog triggers.
|
||||||
|
* **Allowed Imports**: `PySide6.*`, `application.*`, `domain.*`.
|
||||||
|
* **Forbidden**: Direct database queries, raw LLM API calls, disk writes outside UI cache, executing tool commands directly.
|
||||||
|
* **Constraints**: Every widget file must strictly be **under 400 lines of code (LOC)**.
|
||||||
|
|
||||||
|
### Tier 2: Application Layer (`application/`)
|
||||||
|
* **Responsibilities**: Orchestrate single use cases (e.g. `ConversationApplicationService`, `RoutingApplicationService`, `TaskApplicationService`). Convert UI requests into domain requests, coordinate domain services with infrastructure adapters.
|
||||||
|
* **Allowed Imports**: `domain.*`, `infrastructure.*` interfaces/contracts, standard Python libraries.
|
||||||
|
* **Forbidden**: `PySide6`, `PyQt5`, `PyQt6`, `ui.*`, `app.*`.
|
||||||
|
* **Nature**: **100% Pure Python**. Must be executable and testable in headless CI environments without a display driver.
|
||||||
|
|
||||||
|
### Tier 3: Domain Layer (`domain/`)
|
||||||
|
* **Responsibilities**: Core domain models, frozen DTO snapshots (`ConversationExecutionRequest`), typed event streams (`AgentEvent`), descriptors (`ToolDescriptor`, `ProviderDescriptor`), deterministic calculation algorithms (`ScheduleCalculator`).
|
||||||
|
* **Allowed Imports**: Standard Python library only (`dataclasses`, `typing`, `enum`, `datetime`, `pathlib`, `abc`).
|
||||||
|
* **Forbidden**: `PySide6`, `PyQt*`, `requests`, `sqlalchemy`, filesystem mutations, OS network calls.
|
||||||
|
* **Nature**: Completely isolated and zero-dependency core.
|
||||||
|
|
||||||
|
### Tier 4: Infrastructure Layer (`infrastructure/`)
|
||||||
|
* **Responsibilities**: Adapters for external systems (OpenAI/Anthropic/Ollama/FPT providers, OS Keyring via `SecretStore`, `AtomicJsonFile` persistence, MCP child processes, filesystem tools).
|
||||||
|
* **Allowed Imports**: Third-party SDKs, OS libraries, `domain.*`.
|
||||||
|
* **Forbidden**: `presentation.*`, `PySide6.QtWidgets`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architectural Rules and Non-Negotiable Invariants
|
||||||
|
|
||||||
|
1. **Zero Qt in Business Logic**:
|
||||||
|
- `domain/` and `application/` must never import `PySide6` or `PyQt*`.
|
||||||
|
- Verified via AST parser script `scripts/check_imports.py`.
|
||||||
|
2. **Immutable Request Snapshots**:
|
||||||
|
- Turns are initiated using immutable frozen dataclasses (`ConversationExecutionRequest`) to decouple runtime state from mutable UI state.
|
||||||
|
3. **Thread Safety and Signal Decoupling**:
|
||||||
|
- AI generation and tool calls run asynchronously in worker threads.
|
||||||
|
- UI updates occur strictly on the Qt main thread by consuming `AgentEvent` streams through Qt Signal bridges.
|
||||||
|
4. **Single Responsibility and Modularity**:
|
||||||
|
- Production files must stay within **400 LOC**.
|
||||||
|
5. **English In-Code Comments**:
|
||||||
|
- Every modified or created line/block must include concise English comments explaining design decisions and processing logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Consequences and Compliance
|
||||||
|
|
||||||
|
* **Positive**:
|
||||||
|
- Full testability: Unit tests run in milliseconds without GUI or network mocks.
|
||||||
|
- Zero circular dependencies: Clear top-down data flow.
|
||||||
|
- Resilience: UI crashes do not corrupt background tasks or files.
|
||||||
|
* **Verification**:
|
||||||
|
- Automated CI gate: `python scripts/check_imports.py` and `python scripts/check_loc.py`.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Danh Mục & Kế Hoạch Cô Lập Mã Nguồn Dormant / Dead Code (Dormant Code Catalog)
|
||||||
|
|
||||||
|
* **Tài liệu**: `docs/architecture/dormant-code.md`
|
||||||
|
* **Thuộc EPIC**: `R01: Architecture Foundation & Characterization`
|
||||||
|
* **Team phụ trách**: 🔵 **Team Duy (Tech Lead)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mục Đích & Nguyên Tắc Quản Trị
|
||||||
|
|
||||||
|
Trong quá trình phát triển nhanh, một số module, hàm hoặc script đã trở thành mã nguồn không hoạt động (**dormant**), mã nguồn thử nghiệm cũ (**legacy prototypes**), hoặc mã nguồn không còn được sử dụng (**dead code**).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> ### 🛡️ NGUYÊN TẮC CÔ LẬP MÃ NGUỒN CŨ:
|
||||||
|
> 1. **Tuyệt đối không import vào các tầng mới**: Các tầng `domain/`, `application/`, `infrastructure/` mới được xây dựng **cấm tuyệt đối import bất kỳ module dormant nào**.
|
||||||
|
> 2. **Không xóa vội vàng khi chưa có test bảo vệ**: Giữ nguyên mã nguồn cũ trong giai đoạn tái cấu trúc R01–R08; chỉ dọn dẹp hoặc xóa sau khi bộ kiểm thử khói E2E (EPIC R10) chạy pass 100%.
|
||||||
|
> 3. **Phân loại rõ ràng trạng thái**: Mỗi module dormant phải được gắn nhãn (DEPRECATED / ISOLATED / PENDING_DELETION).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Bảng Danh Mục Mã Nguồn Dormant / Dead Code Đã Rà Soát
|
||||||
|
|
||||||
|
| STT | File / Module / Ký Hiệu | Trạng Thái Hiện Tại | Lý Do Phân Loại & Phân Tích Kỹ Thuật | Kế Hoạch Xử Lý & Thời Điểm Gỡ Bỏ |
|
||||||
|
| :---: | :--- | :---: | :--- | :--- |
|
||||||
|
| **1** | `requirements (cloud copy).txt` | `PENDING_DELETION` | File sao chép dự phòng tạm thời trong quá khứ, không được tham chiếu bởi bất kỳ quy trình setup nào. | Gỡ bỏ trong EPIC R10 (Packaging & Clean-up). |
|
||||||
|
| **2** | `preview-desktop` | `ISOLATED` | Script shell rỗng/phác thảo cho môi trường dev container cũ. | Cô lập, không liên kết vào build workflow. |
|
||||||
|
| **3** | `scripts/bootstrap_gitea_repo.py` | `ISOLATED` | Script tiện ích bootstrap kho lưu trữ Gitea nội bộ; không thuộc runtime ứng dụng chính. | Di chuyển vào `docs/gitea/` làm tài liệu tham khảo ops. |
|
||||||
|
| **4** | Hàm routing sao chép tại `ui/chat_panel.py#L638` | `DEPRECATED` | Đoạn code logic chọn model lặp lại từ `core/routing/` nằm trực tiếp trong UI widget. | Thay thế hoàn toàn bằng `RoutingApplicationService` trong EPIC R03. |
|
||||||
|
| **5** | Biến toàn cục `state.py::active_project_id` | `DEPRECATED` | Biến global mutable gây race condition khi chạy background task song song. | Thay thế bằng `WorkspaceSession` trong EPIC R06. |
|
||||||
|
| **6** | Các hàm xử lý UI đồng bộ trong `core/tools.py` | `DEPRECATED` | `core/tools.py` chứa mã monolithic vừa xử lý file vừa gọi dialog xác thực trực tiếp. | Phân rã thành `file_tools.py`, `command_tools.py` và `ToolPolicyGateway` trong EPIC R05. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Quy Trình Cô Lập & Kiểm Soát
|
||||||
|
|
||||||
|
1. **Kiểm tra tự động qua AST Guard**:
|
||||||
|
- Bộ script `scripts/check_imports.py` tự động quét để đảm bảo không có bất kỳ import mới nào trỏ tới các thành phần đã đánh dấu deprecated.
|
||||||
|
2. **Kế hoạch dọn dẹp cuối cùng (Release Phase - 31/08/2026)**:
|
||||||
|
- Sau khi hoàn thành EPIC R10 và pass toàn bộ bài test E2E (`tests/e2e/test_smoke.py`), các file đánh dấu `PENDING_DELETION` sẽ được gỡ bỏ khỏi nhánh `main`.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Mô hình chính sách an toàn — CoworkLocal
|
||||||
|
|
||||||
|
R09-T01 · Team Gamma · viết 22/08/2026
|
||||||
|
|
||||||
|
Tài liệu này mô tả **hệ thống đang chạy**, không phải hệ thống mong muốn. Mọi
|
||||||
|
khẳng định đều chỉ tới file và dòng cụ thể để đối chiếu được.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Câu hỏi quan trọng nhất: đây có phải rào chắn an ninh không
|
||||||
|
|
||||||
|
**Không.** `core/agent_security.py` nói thẳng ngay ở đầu file:
|
||||||
|
|
||||||
|
> *"this is a business productivity tool, not a hard security boundary"*
|
||||||
|
|
||||||
|
Điều đó quyết định mọi thứ còn lại. Cụ thể: **mọi tầng dùng AI đều mở khi
|
||||||
|
hỏng** (`allowed=True` khi không gọi được validator, `core/agent_security.py:150`).
|
||||||
|
Mạng chập chờn hay gateway trục trặc thì agent vẫn chạy, không bị khoá cứng.
|
||||||
|
|
||||||
|
Đánh đổi có chủ đích: chọn *dùng được* thay vì *chặn tuyệt đối*. Ai đọc tài
|
||||||
|
liệu này để đánh giá rủi ro cần hiểu đúng điều đó — đây là lớp giảm tai nạn,
|
||||||
|
không phải lớp chống kẻ tấn công có chủ đích.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hai loại quy tắc, đừng lẫn
|
||||||
|
|
||||||
|
| | Quy tắc xác định | Quy tắc do AI phán |
|
||||||
|
|---|---|---|
|
||||||
|
| Cách hoạt động | So khớp mẫu cố định | Hỏi một model |
|
||||||
|
| Kết quả | Luôn giống nhau | Có thể khác nhau giữa hai lần |
|
||||||
|
| Khi hỏng | Vẫn chạy | **Mở** (cho qua) |
|
||||||
|
| Tắt được không | Không — luôn bật | Có, từng tầng một |
|
||||||
|
| Ở đâu | Bộ phân loại mẫu chặn + sandbox | 3 tầng validate |
|
||||||
|
|
||||||
|
Câu ở `core/agent_security.py:250` nói rõ ranh giới:
|
||||||
|
|
||||||
|
> *"always-on block-pattern classifier + sandbox still apply regardless"*
|
||||||
|
|
||||||
|
Nghĩa là **tắt hết ba tầng AI thì vẫn còn hai lớp xác định**. Đây là điểm dễ
|
||||||
|
hiểu nhầm nhất khi đọc màn Cài đặt: mấy công tắc ở đó **chỉ tắt phần AI**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ba tầng AI
|
||||||
|
|
||||||
|
Bật/tắt độc lập trong `agent_security` của `config.json`.
|
||||||
|
|
||||||
|
| Tầng | Kiểm cái gì | Khoá cấu hình | Khi nào chạy |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Prompt | Yêu cầu của chính người dùng | `validate_prompt` | Trước khi agent làm gì |
|
||||||
|
| Attachment | Văn bản trích ra từ tệp đính kèm | `validate_attachments` | Trước khi vào ngữ cảnh model |
|
||||||
|
| Command | `run_command` / `install_package` | `validate_commands` | Trước khi thực thi |
|
||||||
|
|
||||||
|
Cả ba đọc chung một bộ luật: file cục bộ `core/security_rules.py` cộng thêm
|
||||||
|
tài liệu quản trị viên đặt trên OneDrive (nếu có cấu hình). Riêng agent Code
|
||||||
|
dùng bộ luật khác — `RULEforCode.md` thay vì `RULEBASE.md`.
|
||||||
|
|
||||||
|
Công tắc tổng `agent_security.enabled` tắt cả ba.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Chuyện gì xảy ra khi bị chặn
|
||||||
|
|
||||||
|
Theo đúng thứ tự trong `core/agent_security.py:266-273`:
|
||||||
|
|
||||||
|
1. Hiện thông báo trong khung chat — người dùng thấy ngay, kèm lý do
|
||||||
|
2. Ghi `audit_log.record("security_block", …)` — vào nhật ký kiểm toán
|
||||||
|
3. `notify_admin(...)` — gửi email quản trị viên
|
||||||
|
4. Ném `SecurityBlocked` — dừng lượt chạy
|
||||||
|
|
||||||
|
Ba bước đầu **không được phép ném lỗi**. `audit_log.record()` có ghi rõ trong
|
||||||
|
docstring: *"never raises — audit logging must never break a chat turn"*. Ghi
|
||||||
|
nhật ký hỏng không được kéo theo cả phiên làm việc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hỏi người dùng: trạng thái thứ ba
|
||||||
|
|
||||||
|
Ngoài cho/chặn còn một trạng thái nữa mà hệ thống hiện tại **có nhưng chưa gọi
|
||||||
|
tên**: hỏi người dùng.
|
||||||
|
|
||||||
|
`ui/chat_panel.py:1312` kiểm `ctx.project_confirm_commands()` rồi bật
|
||||||
|
`PermissionDialog`. Đó là một quyết định chính sách thật, nhưng nằm rải ở tầng
|
||||||
|
giao diện chứ không phải một kết quả chính thức.
|
||||||
|
|
||||||
|
`domain/security/tool_policy.py` (đề xuất, chờ Team Hoa xác nhận) gộp lại
|
||||||
|
thành ba trạng thái:
|
||||||
|
|
||||||
|
| | Nghĩa |
|
||||||
|
|---|---|
|
||||||
|
| `ALLOW` | Chạy |
|
||||||
|
| `DENY` | Không chạy, có lý do |
|
||||||
|
| `ASK` | Hỏi người dùng đã |
|
||||||
|
|
||||||
|
**`ASK` không phải là `allowed`.** Coi ASK như ALLOW nghĩa là tool chạy trước
|
||||||
|
khi có ai đồng ý — bẫy dễ mắc nhất, đã có test riêng chặn.
|
||||||
|
|
||||||
|
Cổng chính sách **không tự bật hộp thoại**. Nó chỉ trả lời; hỏi ai và hỏi thế
|
||||||
|
nào là việc của tầng giao diện. Nhờ vậy Co4E chạy nền mới dùng chung cổng được
|
||||||
|
với Cowork chạy tương tác — Co4E không hỏi được thì đổi `ASK` thành `DENY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Bí mật
|
||||||
|
|
||||||
|
Từ 21/08 (R02-T05), API key **không còn nằm trong `config.json`**:
|
||||||
|
|
||||||
|
* Lưu trong kho của hệ điều hành qua `KeyringAdapter` — Windows Credential
|
||||||
|
Manager, macOS Keychain, Linux Secret Service
|
||||||
|
* `provider_conf()` đọc từ kho rồi ghép vào dict trả về, nên chỗ gọi không
|
||||||
|
đổi (đường A, `GammaTeam_decisions.md`)
|
||||||
|
* File cũ tự chuyển ở lần mở đầu tiên, có sao lưu trước khi chuyển
|
||||||
|
|
||||||
|
Máy không có kho bí mật (Linux headless, CI) thì **không chuyển** — thà để
|
||||||
|
khoá trong file còn hơn xoá đi rồi người dùng mất khoá.
|
||||||
|
|
||||||
|
Kiểm bằng `python scripts/audit_security.py`, chạy tự động trong CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Sandbox
|
||||||
|
|
||||||
|
`core/sandbox_manager.py` chạy lệnh trong môi trường hạn chế. Luôn bật, không
|
||||||
|
tắt được, không phụ thuộc công tắc AI nào.
|
||||||
|
|
||||||
|
Năng lực khác nhau theo hệ điều hành — ma trận đầy đủ sẽ nằm ở
|
||||||
|
`infrastructure/sandbox/sandbox_capabilities.py` (R09-T06, Hiệp phụ trách).
|
||||||
|
Chỗ này cập nhật khi task đó xong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Những chỗ đã biết là yếu
|
||||||
|
|
||||||
|
Ghi ra để người sau khỏi tưởng đã kín:
|
||||||
|
|
||||||
|
1. **Mở khi hỏng.** Gateway chết là ba tầng AI cho qua hết. Có chủ đích, nhưng
|
||||||
|
nghĩa là không chống được kẻ tấn công biết cách làm validator ngừng trả lời.
|
||||||
|
2. **Bí mật vẫn đi trong bộ nhớ.** Đường A ghép khoá vào dict `provider_conf()`
|
||||||
|
trả về, nên khoá vẫn có thể lọt vào log gỡ lỗi hay ảnh chụp màn hình. Đường
|
||||||
|
B (bỏ hẳn khỏi dict) đã ghi vào nợ kỹ thuật.
|
||||||
|
3. **Bộ luật lấy từ OneDrive không ký số.** Ai sửa được tài liệu đó là sửa được
|
||||||
|
luật.
|
||||||
|
4. **`ASK` chưa được nối vào Co4E.** Co4E chạy nền, chưa có đường hỏi người
|
||||||
|
dùng — hiện phải chọn giữa cho qua hết hoặc chặn hết.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Đối chiếu nhanh
|
||||||
|
|
||||||
|
| Nội dung | Nguồn |
|
||||||
|
|---|---|
|
||||||
|
| Ba tầng AI, mở khi hỏng | `core/agent_security.py:1-25` |
|
||||||
|
| Phân loại mẫu + sandbox luôn bật | `core/agent_security.py:250` |
|
||||||
|
| Thứ tự khi bị chặn | `core/agent_security.py:266-273` |
|
||||||
|
| Nhật ký không được ném lỗi | `core/audit_log.py:46` |
|
||||||
|
| Hỏi người dùng | `ui/chat_panel.py:1312` |
|
||||||
|
| Ba trạng thái chính sách | `domain/security/tool_policy.py` |
|
||||||
|
| Bí mật | `infrastructure/secrets/keyring_adapter.py` |
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Phân Việc Refactor Team Gamma</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ground: #FAF9FC; --surface: #FFFFFF; --surface-2: #F3F0F8;
|
||||||
|
--ink: #191325; --muted: #665E7C; --line: #E4DEEE;
|
||||||
|
--accent: #6D3A9E;
|
||||||
|
--lead: #6D3A9E; --m1: #14707F; --m2: #A65418;
|
||||||
|
--warn: #A81F1A; --ok: #1B6B40;
|
||||||
|
--lead-wash: #F1E9F9; --m1-wash: #E2F1F3; --m2-wash: #F8EDE2;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
||||||
|
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
||||||
|
--accent: #C08CF0;
|
||||||
|
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
||||||
|
--warn: #F08A84; --ok: #6FD69C;
|
||||||
|
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
||||||
|
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
||||||
|
--accent: #C08CF0;
|
||||||
|
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
||||||
|
--warn: #F08A84; --ok: #6FD69C;
|
||||||
|
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--ground); color: var(--ink);
|
||||||
|
font-family: "Segoe UI", -apple-system, system-ui, "Helvetica Neue", sans-serif;
|
||||||
|
font-size: 15px; line-height: 1.62; -webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 28px 96px; }
|
||||||
|
code, .mono, td.day, th.day, .num {
|
||||||
|
font-family: Consolas, "Cascadia Mono", "SF Mono", ui-monospace, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.top { border-bottom: 2px solid var(--ink); padding: 56px 0 22px; margin-bottom: 34px; }
|
||||||
|
.eyebrow { font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
|
||||||
|
color: var(--accent); font-weight: 700; margin: 0 0 14px; }
|
||||||
|
h1 { font-size: clamp(30px, 4.4vw, 44px); line-height: 1.1; margin: 0 0 16px;
|
||||||
|
font-weight: 700; letter-spacing: -.02em; text-wrap: balance; }
|
||||||
|
.lede { font-size: 17px; color: var(--muted); margin: 0; max-width: 64ch; }
|
||||||
|
.alias { margin: 18px 0 0; padding: 12px 16px; max-width: 74ch;
|
||||||
|
background: var(--surface-2); border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 3px; font-size: 14px; color: var(--muted); }
|
||||||
|
.alias b { color: var(--ink); }
|
||||||
|
.facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 26px;
|
||||||
|
padding-top: 20px; border-top: 1px solid var(--line); }
|
||||||
|
.fact .k { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||||
|
color: var(--muted); display: block; margin-bottom: 3px; }
|
||||||
|
.fact .v { font-size: 15px; font-weight: 600; }
|
||||||
|
|
||||||
|
h2 { font-size: 23px; margin: 52px 0 6px; letter-spacing: -.01em; font-weight: 700; text-wrap: balance; }
|
||||||
|
h2 + .sub { color: var(--muted); margin: 0 0 22px; max-width: 70ch; }
|
||||||
|
h3 { font-size: 17px; margin: 30px 0 10px; font-weight: 700; }
|
||||||
|
|
||||||
|
/* gate = việc phải xong trước khi chia nhánh */
|
||||||
|
.gatebox { background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-left: 4px solid var(--warn); border-radius: 3px; padding: 4px 26px 22px; }
|
||||||
|
.gatebox h2 { margin-top: 22px; }
|
||||||
|
|
||||||
|
.steps { list-style: none; counter-reset: s; padding: 0; margin: 0; }
|
||||||
|
.steps > li { counter-increment: s; position: relative; padding: 14px 0 14px 46px;
|
||||||
|
border-bottom: 1px solid var(--line); }
|
||||||
|
.steps > li:last-child { border-bottom: none; }
|
||||||
|
.steps > li::before {
|
||||||
|
content: counter(s); position: absolute; left: 0; top: 14px;
|
||||||
|
width: 26px; height: 26px; border-radius: 50%; background: var(--accent);
|
||||||
|
color: #fff; font-size: 13px; font-weight: 700; display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
font-family: Consolas, ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.steps b { display: block; margin-bottom: 2px; }
|
||||||
|
.steps small { color: var(--muted); font-size: 13.5px; display: block; }
|
||||||
|
.est { float: right; font-size: 12px; color: var(--muted); font-weight: 600;
|
||||||
|
font-family: Consolas, ui-monospace, monospace; }
|
||||||
|
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
@media (max-width: 940px) { .cards { grid-template-columns: 1fr; } }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-top: 3px solid var(--c); border-radius: 3px; padding: 20px;
|
||||||
|
display: flex; flex-direction: column; }
|
||||||
|
.card.lead { --c: var(--lead); --w: var(--lead-wash); }
|
||||||
|
.card.one { --c: var(--m1); --w: var(--m1-wash); }
|
||||||
|
.card.two { --c: var(--m2); --w: var(--m2-wash); }
|
||||||
|
.card .tag { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||||
|
font-weight: 700; color: var(--c); margin-bottom: 6px; }
|
||||||
|
.card h3 { margin: 0 0 4px; font-size: 18px; }
|
||||||
|
.card .who { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
||||||
|
.card .branch { font-size: 12.5px; background: var(--w); color: var(--c);
|
||||||
|
padding: 5px 9px; border-radius: 3px; display: inline-block;
|
||||||
|
margin-bottom: 16px; word-break: break-all; font-weight: 600; }
|
||||||
|
.card h4 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
|
||||||
|
color: var(--muted); margin: 16px 0 7px; font-weight: 700; }
|
||||||
|
.card ul { margin: 0; padding-left: 17px; font-size: 14px; }
|
||||||
|
.card li { margin-bottom: 6px; }
|
||||||
|
.tid { font-size: 12px; font-weight: 700; color: var(--c);
|
||||||
|
font-family: Consolas, ui-monospace, monospace; }
|
||||||
|
.paths { list-style: none; padding: 0; margin: 0; font-size: 12.5px; }
|
||||||
|
.paths li { padding: 3px 0; border-bottom: 1px dotted var(--line);
|
||||||
|
font-family: Consolas, ui-monospace, monospace; color: var(--muted); word-break: break-all; }
|
||||||
|
.paths li:last-child { border-bottom: none; }
|
||||||
|
.weight { margin-top: auto; padding-top: 16px; font-size: 12.5px; color: var(--muted); }
|
||||||
|
.weight b { color: var(--ink); font-size: 15px; }
|
||||||
|
|
||||||
|
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 3px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 13.5px; background: var(--surface); }
|
||||||
|
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
thead th { background: var(--surface-2); font-size: 11px; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: var(--muted); font-weight: 700; white-space: nowrap; }
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
td.day, th.day { white-space: nowrap; font-weight: 700; font-size: 13px; }
|
||||||
|
td.cl { border-left: 3px solid var(--lead); }
|
||||||
|
td.c1 { border-left: 3px solid var(--m1); }
|
||||||
|
td.c2 { border-left: 3px solid var(--m2); }
|
||||||
|
tr.mark td { background: var(--surface-2); font-weight: 600; }
|
||||||
|
td small { color: var(--muted); display: block; font-size: 12.5px; }
|
||||||
|
.pill { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 7px;
|
||||||
|
border-radius: 2px; letter-spacing: .04em; white-space: nowrap; }
|
||||||
|
.pill.cp { background: var(--m1-wash); color: var(--m1); }
|
||||||
|
.pill.gate { background: var(--m2-wash); color: var(--m2); }
|
||||||
|
.pill.ship { background: var(--lead-wash); color: var(--lead); }
|
||||||
|
|
||||||
|
.rules { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||||
|
@media (max-width: 760px) { .rules { grid-template-columns: 1fr; } }
|
||||||
|
.rule { background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 3px; padding: 18px 20px; border-left: 3px solid var(--c, var(--line)); }
|
||||||
|
.rule.hard { --c: var(--warn); }
|
||||||
|
.rule.soft { --c: var(--ok); }
|
||||||
|
.rule h3 { margin: 0 0 8px; font-size: 15px; }
|
||||||
|
.rule p { margin: 0; font-size: 14px; color: var(--muted); }
|
||||||
|
.rule code { color: var(--ink); }
|
||||||
|
|
||||||
|
|
||||||
|
/* tóm tắt: đọc 30 giây là nắm được, trước khi vào chi tiết */
|
||||||
|
.tldr {
|
||||||
|
display: grid; grid-template-columns: 1.35fr 1fr; gap: 0;
|
||||||
|
border: 1px solid var(--line); border-radius: 3px; overflow: hidden;
|
||||||
|
margin-bottom: 8px; background: var(--surface);
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) { .tldr { grid-template-columns: 1fr; } }
|
||||||
|
.tldr > div { padding: 20px 24px; }
|
||||||
|
.tldr .right { background: var(--surface-2); border-left: 1px solid var(--line); }
|
||||||
|
@media (max-width: 820px) { .tldr .right { border-left: none; border-top: 1px solid var(--line); } }
|
||||||
|
.tldr .cap {
|
||||||
|
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
color: var(--muted); font-weight: 700; margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.flow { list-style: none; padding: 0; margin: 0; font-size: 14px; }
|
||||||
|
.flow li { padding: 7px 0; border-bottom: 1px dotted var(--line); display: flex; gap: 10px; }
|
||||||
|
.flow li:last-child { border-bottom: none; }
|
||||||
|
.flow .b {
|
||||||
|
flex: 0 0 auto; font-size: 11.5px; font-weight: 700; padding: 1px 7px; border-radius: 2px;
|
||||||
|
background: var(--w2); color: var(--c2); height: fit-content; margin-top: 2px;
|
||||||
|
font-family: Consolas, ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.flow li.f0 { --c2: var(--warn); --w2: var(--surface-2); }
|
||||||
|
.flow li.f1 { --c2: var(--lead); --w2: var(--lead-wash); }
|
||||||
|
.flow li.f2 { --c2: var(--m1); --w2: var(--m1-wash); }
|
||||||
|
.flow li.f3 { --c2: var(--m2); --w2: var(--m2-wash); }
|
||||||
|
.flow .t { flex: 1; }
|
||||||
|
.flow .t b { display: block; }
|
||||||
|
.flow .t small { color: var(--muted); font-size: 12.5px; }
|
||||||
|
.must { margin: 0; padding-left: 18px; font-size: 14px; }
|
||||||
|
.must li { margin-bottom: 8px; }
|
||||||
|
.must li:last-child { margin-bottom: 0; }
|
||||||
|
.must b { color: var(--ink); }
|
||||||
|
|
||||||
|
/* input / output từng người */
|
||||||
|
.io { display: grid; gap: 18px; }
|
||||||
|
.iorow { background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--c); border-radius: 3px; overflow: hidden; }
|
||||||
|
.iorow.n1 { --c: var(--lead); --w: var(--lead-wash); }
|
||||||
|
.iorow.n2 { --c: var(--m1); --w: var(--m1-wash); }
|
||||||
|
.iorow.n3 { --c: var(--m2); --w: var(--m2-wash); }
|
||||||
|
.iohead { padding: 14px 20px; background: var(--w); display: flex;
|
||||||
|
align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.iohead b { color: var(--c); font-size: 15px; }
|
||||||
|
.iohead span { color: var(--muted); font-size: 13px; }
|
||||||
|
.iogrid { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
|
@media (max-width: 860px) { .iogrid { grid-template-columns: 1fr; } }
|
||||||
|
.iogrid > div { padding: 16px 20px; }
|
||||||
|
.iogrid > div + div { border-left: 1px solid var(--line); }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.iogrid > div + div { border-left: none; border-top: 1px solid var(--line); }
|
||||||
|
}
|
||||||
|
.iocap { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||||
|
color: var(--muted); font-weight: 700; margin: 0 0 10px; }
|
||||||
|
.iolist { list-style: none; margin: 0; padding: 0; font-size: 13.5px; }
|
||||||
|
.iolist li { padding: 5px 0; border-bottom: 1px dotted var(--line); }
|
||||||
|
.iolist li:last-child { border-bottom: none; }
|
||||||
|
.iolist code { font-size: 12.5px; }
|
||||||
|
.frm { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 6px;
|
||||||
|
border-radius: 2px; background: var(--surface-2); color: var(--muted);
|
||||||
|
margin-right: 6px; font-family: Consolas, ui-monospace, monospace; }
|
||||||
|
.frm.done { background: var(--m1-wash); color: var(--m1); }
|
||||||
|
.frm.risk { background: var(--m2-wash); color: var(--m2); }
|
||||||
|
|
||||||
|
footer { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--line);
|
||||||
|
font-size: 13px; color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="top">
|
||||||
|
<p class="eyebrow">Team Gamma · Automation, Workflows & Governance</p>
|
||||||
|
<h1>Một nhánh chung, ba làn không đụng nhau</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Toàn bộ phần việc refactor 10 ngày của Team Gamma — Nam, Hiệp, Lâm. Cả ba đẩy chung vào <code>gamma/refactor</code>. Nam làm thêm một mục chung —
|
||||||
|
khung kiến trúc, hợp đồng dữ liệu, cổng kiểm duyệt — nằm ngoài ba nhánh; xong mục đó thì
|
||||||
|
ba người vào ba nhánh tính năng ngang nhau, không ai phải sửa chung file với ai.
|
||||||
|
</p>
|
||||||
|
<p class="alias">
|
||||||
|
Ba tài liệu refactor gọi team này là <b>“Team Nam”</b> (theo tên lead). Cùng một team, cùng
|
||||||
|
phạm vi R02 · R08 · R09 · R07-T06. Nhánh của team dùng tiền tố <code>gamma/</code>; ba tài liệu refactor viết
|
||||||
|
<code>nam/workflow-governance-*</code> theo tên lead — cùng một thứ.
|
||||||
|
</p>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="k">Thời hạn</span><span class="v mono">21/08 → 31/08</span></div>
|
||||||
|
<div class="fact"><span class="k">Người</span><span class="v mono">Nam · Hiệp · Lâm</span></div>
|
||||||
|
<div class="fact"><span class="k">Nhánh</span><span class="v mono">gamma/refactor</span></div>
|
||||||
|
<div class="fact"><span class="k">Code phải bóc</span><span class="v mono">~6.500 dòng</span></div>
|
||||||
|
<div class="fact"><span class="k">Cổng phải qua</span><span class="v mono">CASAN Check 1</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="tldr">
|
||||||
|
<div>
|
||||||
|
<p class="cap">Tóm tắt · thứ tự làm</p>
|
||||||
|
<ul class="flow">
|
||||||
|
<li class="f0">
|
||||||
|
<span class="b">CHUNG</span>
|
||||||
|
<span class="t"><b>Nam làm trước, nửa ngày</b>
|
||||||
|
<small>Dựng khung 5 thư mục (đang là 0 file) · interface + fake cho Config/Secrets ·
|
||||||
|
chốt <code>api_key</code> và báo Team Duy · script CASAN Check 1 · đưa 3 check vào CI ·
|
||||||
|
quyết số phận 24 checker UI. Merge xong mới chia nhánh.</small></span>
|
||||||
|
</li>
|
||||||
|
<li class="f1">
|
||||||
|
<span class="b">N1</span>
|
||||||
|
<span class="t"><b>N1 — Nam · Cấu hình, Bí mật, Vỏ ứng dụng</b>
|
||||||
|
<small>R02 (6 task) · settings 4 widget · bootstrap + MainWindow · policy doc.
|
||||||
|
Giữ luôn <code>app.py</code>, <code>config.py</code>, <code>theme.py</code>,
|
||||||
|
<code>i18n.py</code>. ~2.700 dòng.</small></span>
|
||||||
|
</li>
|
||||||
|
<li class="f2">
|
||||||
|
<span class="b">N2</span>
|
||||||
|
<span class="t"><b>N2 — Hiệp · Giám sát</b>
|
||||||
|
<small>7 tab Monitoring · CanonicalAuditLogger · MonitoringQueryService ·
|
||||||
|
2 vòng lặp import · ma trận Sandbox. ~2.650 dòng.</small></span>
|
||||||
|
</li>
|
||||||
|
<li class="f3">
|
||||||
|
<span class="b">N3</span>
|
||||||
|
<span class="t"><b>N3 — Lâm · Co4E Studio</b>
|
||||||
|
<small>Co4EWorkflowService · tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code>
|
||||||
|
thành 5 phần. ~2.880 dòng, file to nhất team.</small></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<p class="cap">Ba điều bắt buộc</p>
|
||||||
|
<ol class="must">
|
||||||
|
<li><b>Không chạm file dùng chung.</b> Cần thêm chuỗi hay màu thì nhắn nhóm trưởng, đừng tự sửa.</li>
|
||||||
|
<li><b>Nộp factory, không tự lắp vào <code>app.py</code>.</b> N1 lắp trong <code>bootstrap.py</code> ngày 28/08.</li>
|
||||||
|
<li><b>Bị chặn thì dùng fake, báo ngay trong ngày.</b> Không ngồi đợi ai.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="cap" style="margin-top:20px">Nghiệm thu</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:var(--muted)">
|
||||||
|
Trên <code>gamma/refactor</code>: không file nào được sửa bởi hai người khác nhau.
|
||||||
|
Có là quy ước <b style="color:var(--ink)">số 1</b> đang bị vi phạm.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="gatebox">
|
||||||
|
<h2>Mục chung — Nam làm, xong hai người kia mới bắt đầu</h2>
|
||||||
|
<p class="sub">
|
||||||
|
Sáu việc dưới đây không thuộc làn nào — chúng là thứ cả ba người cùng đụng
|
||||||
|
vào. Nam làm một lần và đẩy lên <code>gamma/refactor</code>, rồi hai người kia
|
||||||
|
mới bắt đầu. Ước tính nửa ngày.
|
||||||
|
</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>
|
||||||
|
<span class="est">~30 phút</span>
|
||||||
|
<b>Dựng khung thư mục</b>
|
||||||
|
<small>
|
||||||
|
<code>domain/</code> <code>application/</code> <code>infrastructure/</code>
|
||||||
|
<code>presentation/</code> <code>platform/</code> <code>tests/fakes/</code> —
|
||||||
|
hiện tại <b>chưa tồn tại, 0 file</b>. Mọi task của cả ba người đều ghi vào đây; để ba
|
||||||
|
người tự tạo là đụng nhau ở <code>__init__.py</code> ngay ngày đầu.
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="est">~45 phút</span>
|
||||||
|
<b>Viết interface + fake cho Config và Secrets</b>
|
||||||
|
<small>
|
||||||
|
<code>SecretStore</code>, <code>ConfigRepository</code>, kèm
|
||||||
|
<code>FakeSecretStore</code> và <code>FakeConfigRepository</code>. Chỉ chữ ký, chưa cần
|
||||||
|
thân hàm. Đây là thứ gỡ chốt cho cả hai người kia — <b>156 lời gọi
|
||||||
|
<code>ctx.config.*</code> trong 29 file</b> đang chờ nó.
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="est">~20 phút</span>
|
||||||
|
<b>Chốt số phận <code>api_key</code> và báo Team Duy</b>
|
||||||
|
<small>
|
||||||
|
<code>provider_conf()</code> còn trả <code>api_key</code> bên trong, hay tách hẳn sang
|
||||||
|
<code>SecretStore</code>? Có 5 nơi đọc trực tiếp, <b>3 trong số đó nằm trong
|
||||||
|
<code>providers/</code> của Team Duy</b>. Quyết một mình rồi im lặng là làm vỡ code
|
||||||
|
team bạn.
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="est">~30 phút</span>
|
||||||
|
<b>Viết <code>scripts/audit_security.py</code> (CASAN Check 1)</b>
|
||||||
|
<small>
|
||||||
|
Gamma chủ trì check này ngày 30/08. Viết ngay hôm nay thì lead tự kiểm được trong suốt
|
||||||
|
quá trình chuyển API key, thay vì tới ngày cổng mới chạy lần đầu và phát hiện vấn đề.
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="est">~20 phút</span>
|
||||||
|
<b>Thêm 3 check CASAN vào CI</b>
|
||||||
|
<small>
|
||||||
|
CI hiện chỉ chạy <code>pytest tests -q</code>. Ba check (secret · ≤400 dòng · import
|
||||||
|
guard) không nằm trong CI, nên tới 30/08 mới biết ai vi phạm. Đưa vào CI thì mỗi PR tự
|
||||||
|
báo.
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="est">~30 phút</span>
|
||||||
|
<b>Quyết số phận 24 checker UI, rồi thông báo</b>
|
||||||
|
<small>
|
||||||
|
Chúng bám vào <code>cowork_local.config</code> (34 chỗ) và <code>cowork_local.app</code>
|
||||||
|
(16 chỗ) — <b>sẽ chết ngay khi lead đụng <code>config.py</code></b>. Đây là lưới an toàn
|
||||||
|
duy nhất cho phần UI vừa làm xong. Xem mục quy ước bên dưới.
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2>Ba làn</h2>
|
||||||
|
<p class="sub">
|
||||||
|
Ba làn ngang nhau, mỗi làn khoảng 2.700 dòng phải bóc tách, <b>cùng đẩy vào một
|
||||||
|
nhánh</b> <code>gamma/refactor</code>. Nam nhận làn N1 vì đó là làn chạm tới file
|
||||||
|
dùng chung nhiều nhất. Cột “sở hữu” là danh sách file <em>chỉ</em> người đó được
|
||||||
|
sửa — trên nhánh chung, đây là thứ duy nhất giữ cho ba người không giẫm chân.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
|
||||||
|
<div class="card lead">
|
||||||
|
<div class="tag">Làn N1 · Nam</div>
|
||||||
|
<h3>Cấu hình, Bí mật & Vỏ ứng dụng</h3>
|
||||||
|
<p class="who">Nam giữ — làn chạm nhiều file dùng chung nhất</p>
|
||||||
|
<div class="branch">gamma/refactor</div>
|
||||||
|
|
||||||
|
<h4>Việc</h4>
|
||||||
|
<ul>
|
||||||
|
<li><span class="tid">R02-T01…T06</span> AtomicJsonFile · ConfigRepository · Typed Settings Facade · SecretStore + Keyring · chuyển API key · schema versioning</li>
|
||||||
|
<li><span class="tid">R08-T07</span> tách <code>settings_dialog.py</code> → 4 section widget</li>
|
||||||
|
<li><span class="tid">R08-T10</span> <code>bootstrap.py</code> + tách <code>MainWindow</code> → shell · tray · lifecycle <em>(cuối sprint, lắp factory của hai người kia)</em></li>
|
||||||
|
<li><span class="tid">R09-T01</span> tài liệu Security Policy Model</li>
|
||||||
|
<li>Chủ trì <b>CASAN Check 1</b> · giữ CI · duyệt PR của hai người</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Sở hữu độc quyền</h4>
|
||||||
|
<ul class="paths">
|
||||||
|
<li>config.py</li>
|
||||||
|
<li>app.py → presentation/shell/</li>
|
||||||
|
<li>bootstrap.py</li>
|
||||||
|
<li>theme.py · i18n.py</li>
|
||||||
|
<li>infrastructure/config/ · secrets/ · persistence/</li>
|
||||||
|
<li>ui/settings_dialog.py → presentation/settings/</li>
|
||||||
|
<li>scripts/ · .gitea/workflows/</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="weight"><b>~2.700 dòng</b> · 727 settings + 1.352 app + 616 config<br>+ mục chung ở trên</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card one">
|
||||||
|
<div class="tag">Làn N2 · Hiệp</div>
|
||||||
|
<h3>Giám sát & Quan trắc</h3>
|
||||||
|
<p class="who">Hiệp — 7 tab, việc lặp cần kỷ luật</p>
|
||||||
|
<div class="branch">gamma/refactor</div>
|
||||||
|
|
||||||
|
<h4>Việc</h4>
|
||||||
|
<ul>
|
||||||
|
<li><span class="tid">R08-T08</span> tách <code>monitoring_tab.py</code> → 7 tab độc lập</li>
|
||||||
|
<li><span class="tid">R09-T04</span> <code>CanonicalAuditLogger</code></li>
|
||||||
|
<li><span class="tid">R09-T05</span> <code>MonitoringQueryService</code> read-only, phân trang</li>
|
||||||
|
<li><span class="tid">R09-T02</span> gỡ vòng lặp <code>model_pricing</code> ↔ <code>usage_tracker</code></li>
|
||||||
|
<li><span class="tid">R09-T03</span> gỡ vòng lặp <code>agent_security</code> ↔ <code>alert</code></li>
|
||||||
|
<li><span class="tid">R09-T06</span> ma trận Sandbox theo hệ điều hành</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Sở hữu độc quyền</h4>
|
||||||
|
<ul class="paths">
|
||||||
|
<li>ui/monitoring_tab.py → presentation/monitoring/</li>
|
||||||
|
<li>application/monitoring/</li>
|
||||||
|
<li>infrastructure/telemetry/ · sandbox/</li>
|
||||||
|
<li>core/audit_log.py</li>
|
||||||
|
<li>core/model_pricing.py · usage_tracker.py</li>
|
||||||
|
<li>core/agent_security*.py</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="weight"><b>~2.650 dòng</b> · 1.545 monitoring + ~1.100 core</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card two">
|
||||||
|
<div class="tag">Làn N3 · Lâm</div>
|
||||||
|
<h3>Co4E Studio</h3>
|
||||||
|
<p class="who">Lâm — canvas và luồng chạy workflow</p>
|
||||||
|
<div class="branch">gamma/refactor</div>
|
||||||
|
|
||||||
|
<h4>Việc</h4>
|
||||||
|
<ul>
|
||||||
|
<li><span class="tid">R07-T06</span> <code>Co4EWorkflowService</code> thuần Python</li>
|
||||||
|
<li><span class="tid">R08-T09</span> tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code> → canvas · node property · run control · chat view · agent list</li>
|
||||||
|
<li>Gọi tool qua <code>ToolPolicyGateway</code> của Team Hoa — dùng fake, không chờ</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Sở hữu độc quyền</h4>
|
||||||
|
<ul class="paths">
|
||||||
|
<li>ui/co4e_tab.py → presentation/co4e/</li>
|
||||||
|
<li>ui/co4e_canvas.py</li>
|
||||||
|
<li>ui/co4e_config_panel.py</li>
|
||||||
|
<li>application/workflows/</li>
|
||||||
|
<li>domain/workflows/</li>
|
||||||
|
<li>core/co4e_run_manager.py</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="weight"><b>~2.880 dòng</b> · file to nhất của cả team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Tám quy ước</h2>
|
||||||
|
<p class="sub">
|
||||||
|
Tám điều dưới đây là luật của team, Nam chốt. Bốn điều đầu là bắt buộc — trên một
|
||||||
|
nhánh chung, vi phạm không chỉ hại mình mà chặn cả hai người kia.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="rules">
|
||||||
|
|
||||||
|
<div class="rule hard">
|
||||||
|
<h3>1 · Không chạm file dùng chung</h3>
|
||||||
|
<p>
|
||||||
|
<code>app.py</code>, <code>theme.py</code>, <code>i18n.py</code>, <code>config.py</code>,
|
||||||
|
<code>bootstrap.py</code> thuộc nhánh N1 của Nam. Cần thêm chuỗi hay token màu thì
|
||||||
|
<b>nhắn, đừng sửa</b> — Nam thêm trong ngày. Đây là ba file duy nhất có thể gây conflict
|
||||||
|
thật, và luật này xoá hẳn khả năng đó.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule hard">
|
||||||
|
<h3>2 · Nộp factory, không tự lắp vào app</h3>
|
||||||
|
<p>
|
||||||
|
Mỗi nhánh expose một hàm dựng widget với chữ ký chốt từ ngày đầu, ví dụ
|
||||||
|
<code>build_monitoring_tab(ctx, query_service) -> QWidget</code>. Nam gọi nó trong <code>bootstrap.py</code> ngày 28/08. Không ai tự sửa chỗ khởi tạo trong
|
||||||
|
<code>app.py</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule hard">
|
||||||
|
<h3>3 · Bị chặn thì dùng fake, không ngồi đợi</h3>
|
||||||
|
<p>
|
||||||
|
Chưa có <code>ConfigRepository</code> bản thật thì dùng <code>FakeConfigRepository</code>.
|
||||||
|
Chưa có <code>ToolPolicyGateway</code> của Team Hoa thì đã có fake sẵn. <b>Báo ngay trong ngày</b>
|
||||||
|
nếu thiếu fake nào — đó là việc của nhóm trưởng, không phải lý do dừng tay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rule hard">
|
||||||
|
<h3>4 · Nhánh chung: kéo trước khi đẩy, đừng để nhánh đỏ</h3>
|
||||||
|
<p>
|
||||||
|
Cả ba đẩy vào <code>gamma/refactor</code>, nên không còn nhánh riêng làm vùng
|
||||||
|
đệm. Ba việc bắt buộc: <code>git pull --rebase</code> trước mỗi lần đẩy;
|
||||||
|
commit nhỏ và đẩy trong ngày, đừng ôm 500 dòng ba hôm; và
|
||||||
|
<b>không bao giờ đẩy thứ làm <code>pytest tests -q</code> đỏ</b> — nhánh hỏng
|
||||||
|
là hai người kia đứng hình. Lỡ đẩy nhầm thì sửa ngay hoặc
|
||||||
|
<code>git revert</code>, đừng để qua đêm.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule soft">
|
||||||
|
<h3>5 · Commit nhỏ, mỗi ngày một lần</h3>
|
||||||
|
<p>
|
||||||
|
Một PR cho một sub-widget hoặc một service, không dồn 7 tab vào một PR cuối tuần. Nhóm
|
||||||
|
trưởng duyệt trong ngày. PR càng to thì rủi ro càng dồn về ngày 28/08.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule soft">
|
||||||
|
<h3>6 · Mỗi commit kèm test, và không làm đỏ 90 test cũ</h3>
|
||||||
|
<p>
|
||||||
|
Baseline hiện tại: <b>102 test xanh trong 3,4 giây</b>. Chạy <code>pytest tests -q</code>
|
||||||
|
trước mỗi lần đẩy. Đây là lưới an toàn cho phần logic — giữ nó xanh suốt 10 ngày.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule soft">
|
||||||
|
<h3>7 · File mới ≤ 400 dòng, không import PySide6 vào lõi</h3>
|
||||||
|
<p>
|
||||||
|
Hai điều kiện của CASAN Check 2 và 3. Tự kiểm trước khi đẩy — CI sẽ báo, nhưng biết
|
||||||
|
sớm thì đỡ phải tách lại lần hai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule soft">
|
||||||
|
<h3>8 · Checker UI thuộc phạm vi ai, người đó cập nhật</h3>
|
||||||
|
<p>
|
||||||
|
24 checker sẽ vỡ khi file bị dời. Ai dời file thì sửa checker tương ứng ngay trong
|
||||||
|
commit đó — tốn thêm khoảng 15% thời gian, đổi lại giữ được lưới an toàn cho phần
|
||||||
|
UI vừa làm xong.
|
||||||
|
<b>Đã chốt 21/08: đường A. Nam chịu trách nhiệm nếu đổi ý.</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<h2>Mỗi người nhận gì, giao gì</h2>
|
||||||
|
<p class="sub">
|
||||||
|
Cột trái là thứ phải có trong tay mới làm được, kèm nguồn. Cột phải là thứ bắt
|
||||||
|
buộc giao ra, kèm người nhận. Nhãn <span class="frm done">có rồi</span> nghĩa là
|
||||||
|
mục chung đã làm xong.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="io">
|
||||||
|
|
||||||
|
<div class="iorow n1">
|
||||||
|
<div class="iohead"><b>N1 · Cấu hình, Bí mật & Vỏ</b><span>Nam · nhóm trưởng</span></div>
|
||||||
|
<div class="iogrid">
|
||||||
|
<div>
|
||||||
|
<p class="iocap">Input — cần có</p>
|
||||||
|
<ul class="iolist">
|
||||||
|
<li><span class="frm">mã cũ</span><code>config.py</code> 616 dòng</li>
|
||||||
|
<li><span class="frm">mã cũ</span><code>ui/settings_dialog.py</code> 727 dòng</li>
|
||||||
|
<li><span class="frm">mã cũ</span><code>app.py</code> 1.352 dòng</li>
|
||||||
|
<li><span class="frm risk">tự chốt</span>Quyết định <code>api_key</code> — trước 26/08</li>
|
||||||
|
<li><span class="frm">từ Hiệp</span>Chữ ký <code>build_monitoring_tab()</code> — trước 28/08</li>
|
||||||
|
<li><span class="frm">từ Lâm</span>Chữ ký <code>build_co4e_tab()</code> — trước 28/08</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="iocap">Output — phải giao</p>
|
||||||
|
<ul class="iolist">
|
||||||
|
<li><span class="frm done">có rồi</span><code>SecretStore</code> · <code>ConfigRepository</code> + fake → <b>cho Hiệp và Lâm</b></li>
|
||||||
|
<li><span class="frm done">có rồi</span><code>scripts/audit_security.py</code> → cho CI</li>
|
||||||
|
<li><code>infrastructure/persistence/json/atomic_json_file.py</code></li>
|
||||||
|
<li><code>infrastructure/config/</code> — cài đặt thật + settings facade</li>
|
||||||
|
<li><code>infrastructure/secrets/keyring_adapter.py</code></li>
|
||||||
|
<li><code>presentation/settings/</code> — 4 widget</li>
|
||||||
|
<li><code>bootstrap.py</code> + <code>presentation/shell/</code> — 3 file</li>
|
||||||
|
<li><code>docs/architecture/security-policy.md</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="iorow n2">
|
||||||
|
<div class="iohead"><b>N2 · Giám sát</b><span>Hiệp</span></div>
|
||||||
|
<div class="iogrid">
|
||||||
|
<div>
|
||||||
|
<p class="iocap">Input — cần có</p>
|
||||||
|
<ul class="iolist">
|
||||||
|
<li><span class="frm">mã cũ</span><code>ui/monitoring_tab.py</code> 1.545 dòng</li>
|
||||||
|
<li><span class="frm">mã cũ</span><code>core/usage_tracker.py</code> 524 · <code>sandbox_manager.py</code> 335</li>
|
||||||
|
<li><span class="frm">mã cũ</span><code>core/model_pricing.py</code> 284 · <code>agent_security.py</code> 272 · <code>audit_log.py</code> 115</li>
|
||||||
|
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code> — dùng được ngay</li>
|
||||||
|
<li><span class="frm risk">tự chốt</span>Giữ nguyên 9 trường log, báo Duy và Hoa</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="iocap">Output — phải giao</p>
|
||||||
|
<ul class="iolist">
|
||||||
|
<li><code>build_monitoring_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
||||||
|
<li><code>FakeAuditLogger</code> · <code>FakeMonitoringQueryService</code> → <b>cho cả team</b></li>
|
||||||
|
<li><code>presentation/monitoring/</code> — 7 tab + shell</li>
|
||||||
|
<li><code>application/monitoring/monitoring_query_service.py</code></li>
|
||||||
|
<li><code>infrastructure/telemetry/audit_logger.py</code></li>
|
||||||
|
<li><code>infrastructure/sandbox/sandbox_capabilities.py</code></li>
|
||||||
|
<li><b>0 circular import</b> ở pricing ↔ usage và security ↔ alert</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="iorow n3">
|
||||||
|
<div class="iohead"><b>N3 · Co4E Studio</b><span>Lâm</span></div>
|
||||||
|
<div class="iogrid">
|
||||||
|
<div>
|
||||||
|
<p class="iocap">Input — cần có</p>
|
||||||
|
<ul class="iolist">
|
||||||
|
<li><span class="frm">mã cũ</span><code>ui/co4e_tab.py</code> 2.089 dòng</li>
|
||||||
|
<li><span class="frm">mã cũ</span><code>ui/co4e_canvas.py</code> 791 · <code>co4e_config_panel.py</code></li>
|
||||||
|
<li><span class="frm">mã cũ</span><code>core/co4e_run_manager.py</code> 331</li>
|
||||||
|
<li><span class="frm">có sẵn</span><code>core/co4e.py</code> — dataclass Workflow/Node/Edge đã có</li>
|
||||||
|
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code></li>
|
||||||
|
<li><span class="frm risk">từ Team Hoa</span>DTO <code>ToolPolicyGateway</code> — <b>rủi ro liên team cao nhất</b>, lấy trong hôm nay</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="iocap">Output — phải giao</p>
|
||||||
|
<ul class="iolist">
|
||||||
|
<li><code>build_co4e_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
||||||
|
<li><code>FakeCo4EWorkflowService</code> → <b>cho cả team</b></li>
|
||||||
|
<li><code>domain/workflows/</code> — DTO chốt ngày đầu</li>
|
||||||
|
<li><code>application/workflows/co4e_workflow_service.py</code></li>
|
||||||
|
<li><code>presentation/co4e/</code> — 5 phần</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Output bắt buộc với cả ba, mỗi lần đẩy</h3>
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Điều kiện</th><th>Ngưỡng</th><th>Tự kiểm bằng</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>File mới sau khi tách</td><td class="mono">≤ 400 dòng</td><td class="mono">wc -l</td></tr>
|
||||||
|
<tr><td><code>domain/</code> và <code>application/</code> import PySide6</td><td class="mono">0</td><td class="mono">grep -r PySide6</td></tr>
|
||||||
|
<tr><td>Test hiện có</td><td class="mono">102 xanh</td><td class="mono">pytest tests -q</td></tr>
|
||||||
|
<tr><td>Credential lộ</td><td class="mono">0</td><td class="mono">python scripts/audit_security.py</td></tr>
|
||||||
|
<tr><td>Checker UI trong phạm vi mình dời</td><td>đã cập nhật</td><td class="mono">python tools/check_<tên>.py</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Lịch từng ngày</h2>
|
||||||
|
<p class="sub">Ba hàng chạy độc lập. Hàng tô nền là lúc cả ba phải gặp nhau.</p>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="day">Ngày</th>
|
||||||
|
<th>N1 · Nam</th>
|
||||||
|
<th>N2 · Hiệp</th>
|
||||||
|
<th>N3 · Lâm</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="day">21/08<br><small>T6</small></td>
|
||||||
|
<td class="cl"><b>Mục chung</b> · dựng khung · interface + fake · chốt api_key · CASAN script<small>Merge trước khi hai người kia bắt đầu</small></td>
|
||||||
|
<td class="c1">Chốt schema log 9 trường<small>Giữ nguyên định dạng cũ để 24 chỗ gọi không phải sửa</small></td>
|
||||||
|
<td class="c2">Chốt chữ ký <code>Co4EWorkflowService</code><small>Nộp cho lead để lắp bootstrap sau</small></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">22–23/08<br><small>T7–CN</small></td>
|
||||||
|
<td class="cl">AtomicJsonFile · ConfigRepository · Typed Settings Facade</td>
|
||||||
|
<td class="c1">CanonicalAuditLogger · gỡ vòng lặp pricing ↔ usage</td>
|
||||||
|
<td class="c2">Co4EWorkflowService — CRUD & validate, test không cần Qt</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="mark">
|
||||||
|
<td class="day">23/08<br><small>17:00</small></td>
|
||||||
|
<td colspan="3"><span class="pill cp">Checkpoint 1</span> 100% DTO và fake xong · <code>pytest</code> xanh · không ai bị chặn</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">24/08<br><small>T2</small></td>
|
||||||
|
<td class="cl">Tách settings: provider + connector widget</td>
|
||||||
|
<td class="c1">3 tab đầu: overview · sandbox · security events</td>
|
||||||
|
<td class="c2">node_property_panel · agent_list_panel</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">25/08<br><small>T3</small></td>
|
||||||
|
<td class="cl">Tách settings: routing + general widget</td>
|
||||||
|
<td class="c1">4 tab còn lại: MCP · action logs · agent status · security settings</td>
|
||||||
|
<td class="c2">co4e_canvas_widget — thao tác node</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">26/08<br><small>T4</small></td>
|
||||||
|
<td class="cl">Chuyển API key sang SecretStore<small>Báo Team Duy trước khi đụng providers/</small></td>
|
||||||
|
<td class="c1">Lắp shell MonitoringTab · query service bản thật</td>
|
||||||
|
<td class="c2">Run control · chat view</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">27/08<br><small>T5</small></td>
|
||||||
|
<td class="cl">Schema versioning · recovery policy</td>
|
||||||
|
<td class="c1">Ma trận Sandbox · gỡ vòng lặp agent_security</td>
|
||||||
|
<td class="c2">Lắp container Co4ETab · thay fake bằng service thật</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">28/08<br><small>T6</small></td>
|
||||||
|
<td class="cl"><b>bootstrap.py + tách MainWindow</b><small>Nhận factory của Hiệp và Lâm để lắp</small></td>
|
||||||
|
<td class="c1">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
||||||
|
<td class="c2">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="mark">
|
||||||
|
<td class="day">28/08<br><small>17:00</small></td>
|
||||||
|
<td colspan="3"><span class="pill cp">Checkpoint 2</span> Tách xong 100% god file · 0 circular import</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="day">29/08<br><small>T7</small></td>
|
||||||
|
<td class="cl">Tài liệu Security Policy · integration test Settings</td>
|
||||||
|
<td class="c1">Integration test Monitoring</td>
|
||||||
|
<td class="c2">Integration test luồng Co4E đầu-cuối</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="mark">
|
||||||
|
<td class="day">30/08<br><small>CN 17:00</small></td>
|
||||||
|
<td colspan="3"><span class="pill gate">CASAN Gate</span> <b>Nam chủ trì Check 1</b> — quét toàn bộ config/JSON, phải ra 0 secret plaintext. Hiệp và Lâm sửa ngay phần của mình nếu script bắt được.</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="mark">
|
||||||
|
<td class="day">31/08<br><small>T2 15:00</small></td>
|
||||||
|
<td colspan="3"><span class="pill ship">Bàn giao</span> Fix tồn đọng · cập nhật tài liệu kiến trúc · merge PR cuối · smoke test 5 luồng chính</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Nghiệm thu: làm sao biết đã thật sự song song</h2>
|
||||||
|
<p class="sub">Không phải “đã họp xong” mà là chạy được. Ba câu hỏi, trả lời bằng lệnh.</p>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Câu hỏi</th><th>Cách trả lời</th><th>Khi nào</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Hiệp có chạy được khi chưa có config bản thật?</td>
|
||||||
|
<td>Dựng một tab Monitoring, chạy test của nó, <b>không import <code>cowork_local.config</code></b> dòng nào — chỉ dùng <code>FakeConfigRepository</code></td>
|
||||||
|
<td class="mono">21/08</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Lâm có chạy được khi Team Hoa chưa xong gateway?</td>
|
||||||
|
<td>Test <code>Co4EWorkflowService</code> xanh với <code>FakeToolPolicyGateway</code></td>
|
||||||
|
<td class="mono">23/08</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Ba người có đụng file nhau không?</td>
|
||||||
|
<td><code>git log --name-only --pretty=%an</code> trên <code>gamma/refactor</code> —
|
||||||
|
không file nào được xuất hiện dưới hai tên khác nhau</td>
|
||||||
|
<td class="mono">mỗi ngày</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Nguồn: <code>docs/refactor/plan.md</code>, <code>Refactoring_Checklist.md</code>,
|
||||||
|
<code>Feature_Architecture_Proposal.md</code>. Số dòng code, số lời gọi và baseline test đo
|
||||||
|
trực tiếp trên nhánh <code>main</code> ngày 21/08.
|
||||||
|
Mục chung, cách chia ba nhánh, bảy quy ước và mục nghiệm thu là đề xuất — không có trong
|
||||||
|
tài liệu gốc.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Quyết định của Team Gamma
|
||||||
|
|
||||||
|
Team: **Nam** (nhóm trưởng, nhánh N1) · **Hiệp** (N2) · **Lâm** (N3).
|
||||||
|
|
||||||
|
Ghi ở đây thay vì chôn trong comment, vì cả ba đều ảnh hưởng ra ngoài phạm vi
|
||||||
|
một người.
|
||||||
|
|
||||||
|
| # | Việc | Trạng thái |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `provider_conf()` còn trả `api_key` | **Chốt 21/08 — đường A** |
|
||||||
|
| 2 | Số phận 24 checker UI | **Chốt 21/08 — đường A** |
|
||||||
|
| 3 | DTO `ToolPolicyGateway` viết hộ Team Hoa | Đã làm, chờ Hoa xác nhận |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không
|
||||||
|
|
||||||
|
### Vì sao phải quyết trước khi code
|
||||||
|
|
||||||
|
R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do
|
||||||
|
`provider_conf()` trả về **còn chứa `api_key` không**.
|
||||||
|
|
||||||
|
Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08:
|
||||||
|
|
||||||
|
| Nơi đọc | Thuộc |
|
||||||
|
|---|---|
|
||||||
|
| `providers/anthropic.py:26` | **Team Duy** |
|
||||||
|
| `providers/openai_compat.py:36` | **Team Duy** |
|
||||||
|
| `core/image_gen.py:50` | Team Duy (routing/model) |
|
||||||
|
| `core/ext_connectors.py:98` | Team Hoa |
|
||||||
|
| `ui/ext_connector_dialog.py:87` | Team Gamma |
|
||||||
|
|
||||||
|
Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác.
|
||||||
|
|
||||||
|
### Hai đường
|
||||||
|
|
||||||
|
**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào**
|
||||||
|
|
||||||
|
- 5 nơi đọc **không phải sửa dòng nào**
|
||||||
|
- Không cần báo team khác, không cần đồng bộ lịch
|
||||||
|
- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug
|
||||||
|
- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ
|
||||||
|
|
||||||
|
**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`**
|
||||||
|
|
||||||
|
- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần
|
||||||
|
- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch
|
||||||
|
- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được
|
||||||
|
|
||||||
|
### Đề xuất
|
||||||
|
|
||||||
|
**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.**
|
||||||
|
|
||||||
|
Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A
|
||||||
|
đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng
|
||||||
|
nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi
|
||||||
|
họ không lên kế hoạch.
|
||||||
|
|
||||||
|
Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc
|
||||||
|
đã sửa xong.
|
||||||
|
|
||||||
|
> **Nam chốt 21/08: đường A.**
|
||||||
|
>
|
||||||
|
> Việc kèm theo: `ConfigRepository` bản thật phải đọc key từ `SecretStore` rồi
|
||||||
|
> ghép vào dict do `provider_conf()` trả về. Năm nơi đọc không đổi một dòng,
|
||||||
|
> nên **không cần báo Duy và Hoa**.
|
||||||
|
>
|
||||||
|
> Nợ kỹ thuật đã ghi: đường B (bỏ `api_key` khỏi dict) để sau sprint này.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quyết định 2 — số phận 24 checker UI
|
||||||
|
|
||||||
|
### Vấn đề
|
||||||
|
|
||||||
|
`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện
|
||||||
|
**24 file**. Chúng bám vào đường dẫn cũ:
|
||||||
|
|
||||||
|
| Import | Số chỗ |
|
||||||
|
|---|---|
|
||||||
|
| `cowork_local.config` | 34 |
|
||||||
|
| `cowork_local.app` | 16 |
|
||||||
|
| `cowork_local.state` | 22 |
|
||||||
|
| `cowork_local.ui.*` | ~12 |
|
||||||
|
|
||||||
|
R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết
|
||||||
|
ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao
|
||||||
|
diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic).
|
||||||
|
|
||||||
|
### Ba đường
|
||||||
|
|
||||||
|
**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó**
|
||||||
|
|
||||||
|
- Giữ được lưới suốt 10 ngày
|
||||||
|
- Tốn thêm ~15% thời gian mỗi PR
|
||||||
|
- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một
|
||||||
|
lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ*
|
||||||
|
|
||||||
|
**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08**
|
||||||
|
|
||||||
|
- Nhanh nhất trong 10 ngày
|
||||||
|
- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc
|
||||||
|
dễ vỡ giao diện nhất
|
||||||
|
- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì
|
||||||
|
|
||||||
|
**C. Bỏ hẳn**
|
||||||
|
|
||||||
|
Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor
|
||||||
|
không có gì thay thế cho phần giao diện.
|
||||||
|
|
||||||
|
### Đề xuất
|
||||||
|
|
||||||
|
**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả
|
||||||
|
**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review.
|
||||||
|
|
||||||
|
`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được;
|
||||||
|
chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó.
|
||||||
|
|
||||||
|
> **Chốt 21/08: đường A** — ai dời file thì cập nhật checker tương ứng ngay
|
||||||
|
> trong PR đó.
|
||||||
|
>
|
||||||
|
> Kèm hai ràng buộc, vì rủi ro của đường A là người sửa vội nới lỏng phép kiểm:
|
||||||
|
>
|
||||||
|
> 1. PR nào *sửa* checker phải nói rõ trong mô tả **sửa gì và vì sao**.
|
||||||
|
> 2. Sửa xong chạy `python tools/check_probes_bite.py` — nó cắm lỗi cố ý vào
|
||||||
|
> code rồi kiểm checker có bắt được không. Chính công cụ này đã từng bắt
|
||||||
|
> được một checker bị sửa thành *không thể đỏ*.
|
||||||
|
>
|
||||||
|
> Không đưa 24 checker vào CI trong sprint này: chúng dựng `MainWindow` thật,
|
||||||
|
> mỗi lần chạy tốn hàng chục giây và thỉnh thoảng sập lúc Qt dọn dẹp. Chạy tay
|
||||||
|
> theo phạm vi mình đụng là đủ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quyết định 3 — Gamma viết hộ DTO `ToolPolicyGateway` cho Team Hoa
|
||||||
|
|
||||||
|
**Đã làm, chờ Hoa xác nhận.** Ngày: 21/08.
|
||||||
|
|
||||||
|
### Vì sao làm thay
|
||||||
|
|
||||||
|
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường:
|
||||||
|
|
||||||
|
| | Hệ quả |
|
||||||
|
|---|---|
|
||||||
|
| N3 ngồi đợi Hoa | Mất mấy ngày, trái nguyên tắc "không team nào chặn team nào" |
|
||||||
|
| N3 tự phỏng đoán | Phỏng đoán của một người, không ai soi, sửa lại chắc chắn |
|
||||||
|
| **Gamma viết bản đề xuất** | N3 chạy ngay, Hoa có cái cụ thể để duyệt hoặc sửa |
|
||||||
|
|
||||||
|
### Ranh giới không lấn
|
||||||
|
|
||||||
|
Sơ đồ phân hệ trong `plan.md` giao `domain/security/` cho **Team Gamma**, còn
|
||||||
|
`application/conversations/tool_policy_gateway.py` cho **Team Hoa**.
|
||||||
|
|
||||||
|
Nên chia đúng như vậy:
|
||||||
|
|
||||||
|
- **Gamma định nghĩa hình dạng** → `domain/security/tool_policy.py`
|
||||||
|
- **Hoa cài đặt gateway** → `application/conversations/tool_policy_gateway.py`,
|
||||||
|
nối vào `core/mcp_client.py` và tool dựng sẵn
|
||||||
|
|
||||||
|
Không đụng file nào của Hoa.
|
||||||
|
|
||||||
|
### Đã bám vào code đang chạy, không bịa
|
||||||
|
|
||||||
|
| Nguồn | Lấy gì |
|
||||||
|
|---|---|
|
||||||
|
| `core/agent_security.py::SecurityVerdict` | `allowed` · `reason` · `layer` |
|
||||||
|
| `ui/permission_dialog.py` + `chat_panel.py:1312` | trạng thái "hỏi người dùng" |
|
||||||
|
|
||||||
|
Khác biệt duy nhất: gộp thành **một câu trả lời ba trạng thái**
|
||||||
|
(`ALLOW` / `DENY` / `ASK`) thay vì bắt chỗ gọi tự nhớ hỏi hai nơi.
|
||||||
|
|
||||||
|
Hai ràng buộc đưa vào có chủ đích:
|
||||||
|
|
||||||
|
1. `DENY` và `ASK` **bắt buộc có `reason`** — người dùng cần biết vì sao, và
|
||||||
|
`audit_log` cần ghi lại. Thiếu là ném lỗi ngay lúc dựng, không phải lúc chạy.
|
||||||
|
2. `ASK` **không phải** `allowed` — bẫy dễ mắc nhất là coi ASK như ALLOW rồi tool
|
||||||
|
chạy mà chưa ai đồng ý. Có test riêng cho chuyện này.
|
||||||
|
|
||||||
|
### Gửi Hoa cái gì
|
||||||
|
|
||||||
|
> Bên mình viết trước bản đề xuất `ToolPolicyGateway` ở
|
||||||
|
> `domain/security/tool_policy.py` vì N3 cần gọi tool mà bên Hoa chưa bắt đầu —
|
||||||
|
> để N3 khỏi phải tự đoán. Ba kiểu: `ToolCallRequest`, `PolicyDecision`,
|
||||||
|
> `ToolPolicyGateway`. Phần cài đặt vẫn để bên Hoa ở
|
||||||
|
> `application/conversations/tool_policy_gateway.py`, bọn mình không đụng.
|
||||||
|
> Thấy chỗ nào không hợp thì sửa thẳng file đó, đừng tạo kiểu thứ hai. Đổi bây
|
||||||
|
> giờ còn rẻ vì mới mình N3 dùng.
|
||||||
|
|
||||||
|
> Đã gửi Hoa: ☐ — ngày ____ Hoa xác nhận: ☐ đồng ý ☐ có sửa
|
||||||
@@ -26,16 +26,16 @@
|
|||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
|
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
|
||||||
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
|
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
|
||||||
|
|
||||||
- [ ] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
|
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-21 18:23` | End: `2026-08-21 18:24`*
|
||||||
- [ ] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
|
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-21 18:24` | End: `2026-08-21 18:26`*
|
||||||
- [ ] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
|
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-21 18:26` | End: `2026-08-21 18:28`*
|
||||||
- [ ] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
|
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-21 18:28` | End: `2026-08-21 18:32`*
|
||||||
- [ ] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
|
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-21 18:32` | End: `2026-08-21 18:35`*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,18 +62,72 @@
|
|||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
||||||
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
|
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
|
||||||
|
|
||||||
- [ ] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
|
- [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-22 18:59` | End: `2026-08-22 19:01`*
|
||||||
- [ ] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
|
- [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-22 18:45` | End: `2026-08-22 18:50`*
|
||||||
- [ ] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
|
- [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-22 18:53` | End: `2026-08-22 18:57`*
|
||||||
- [ ] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
|
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-22 18:57` | End: `2026-08-22 18:58`*
|
||||||
- [ ] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
|
- [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-22 18:58` | End: `2026-08-22 18:59`*
|
||||||
- [ ] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
|
- [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-22 18:50` | End: `2026-08-22 18:53`*
|
||||||
|
|
||||||
|
#### 📦 KẾT QUẢ THỰC HIỆN EPIC R03 (Hoàn tất 2026-08-22 19:01 — nhánh `feature/delta-team/epic-R03`)
|
||||||
|
|
||||||
|
**File sản phẩm mới (tất cả < 400 dòng, 100% comment tiếng Anh):**
|
||||||
|
|
||||||
|
| Task | File | LOC | Nội dung chính |
|
||||||
|
| :--- | :--- | :---: | :--- |
|
||||||
|
| T02 | `domain/models/provider_descriptor.py` | 196 | `ProviderDescriptor` (frozen dataclass), `WireProtocol`, `AuthKind`; giá/context để `None` khi chưa biết thay vì đoán bừa |
|
||||||
|
| T02 | `infrastructure/providers/provider_registry.py` | 287 | `ProviderRegistry` thread-safe: tra cứu theo id/alias, **tra cứu động theo model ID** (`find_by_model`), dựng adapter theo wire protocol; `BUILTIN_DESCRIPTORS` cho 5 provider |
|
||||||
|
| T03 | `application/model_routing/routing_models.py` | 158 | DTO thuần Python: `RoutingMode` (Off/Auto/Manual/**Fallback**), `RoutingRequest` (immutable snapshot), `RouteEvaluation`, `RoutingOutcome` |
|
||||||
|
| T03 | `application/model_routing/routing_application_service.py` | 236 | `RoutingApplicationService` — 1 nơi duy nhất quyết định routing; 2 port hẹp (`RoutingDecisionPort`, `ModeResolver`) + callback confirm ⇒ 0 phụ thuộc Qt |
|
||||||
|
| T03 | `application/model_routing/core_routing_adapter.py` | 169 | `CoreRoutingEngine` (cầu nối sang `core/routing`), `AppContextModeResolver`, `build_routing_application_service(ctx)` (cache 1 instance/ctx) |
|
||||||
|
| T06 | `infrastructure/telemetry/usage_sink.py` | 288 | `UsageEvent` + `UsageEventSink` (Protocol) + `UsageTrackerSink` / `InMemoryUsageSink` / `CompositeUsageSink`; publish không bao giờ raise |
|
||||||
|
|
||||||
|
**File hiện hữu được sửa (đều có comment tiếng Anh tại mọi khối thay đổi):**
|
||||||
|
|
||||||
|
| File | Thay đổi |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `providers/factory.py` | Bỏ bảng `_REGISTRY` nội bộ, ủy quyền cho `ProviderRegistry`; vẫn raise `ProviderError` để không vỡ call site cũ |
|
||||||
|
| `providers/openai_compat.py`, `providers/anthropic.py` | Không còn gọi thẳng `core/usage_tracker`; chỉ **publish** `UsageEvent` qua sink (T06) |
|
||||||
|
| `ui/chat_panel.py` (#L638), `ui/co4e_tab.py`, `ui/folder_tab.py` | Xóa 3 bản sao logic routing (~35 dòng/file) ➔ gọi chung `RoutingApplicationService` (T04, T05); widget chỉ còn dựng `RoutingRequest`, host modal confirm và render kết quả |
|
||||||
|
| `config.py`, `state.py`, `ui/routing_toggle.py`, `i18n.py` | Mở đường cho chế độ thứ 4 **Fallback**: hằng `AppConfig.ROUTING_MODES`, validate per-workspace, thêm mục trong combo + chuỗi EN/JA/VI |
|
||||||
|
| `core/usage_tracker.py` | Thêm `current_context()` để sink mượn/trả lại context của thread thay vì gán đè vĩnh viễn |
|
||||||
|
| `tests/conftest.py`, `tests/routing/conftest.py` | **Sửa lỗi hạ tầng test nghiêm trọng** (xem "Ghi chú" bên dưới) |
|
||||||
|
|
||||||
|
**Bộ test bổ sung (tất cả offline, không cần network/Qt):**
|
||||||
|
|
||||||
|
| File | Số test | Phạm vi |
|
||||||
|
| :--- | :---: | :--- |
|
||||||
|
| `tests/contracts/test_providers.py` (+ `provider_stubs.py`) | 50 | Contract chạy parametrize trên **mọi** provider trong registry: signature `chat()`, canonical assistant message, tool call chuẩn hóa, đóng response, dịch tool schema, `ProviderError`, `list_models`/`test_connection`, đúng 1 `UsageEvent`/turn |
|
||||||
|
| `tests/unit/test_routing_application_service.py` | 28 | Đủ 4 chế độ + mọi nhánh degrade (engine lỗi, resolver lỗi, dialog lỗi, thiếu callback) |
|
||||||
|
| `tests/unit/test_provider_registry.py` | 17 | Descriptor + registry + đối chiếu catalogue với `DEFAULT_CONFIG["providers"]` |
|
||||||
|
| `tests/unit/test_core_routing_adapter.py` | 12 | Dịch `RouteResult` ⇄ DTO, task type sai định dạng, thiếu ranking, cache service |
|
||||||
|
| `tests/unit/test_usage_sink.py` | 13 | Fan-out, subscriber lỗi, khôi phục thread context, publish không raise |
|
||||||
|
| `tests/integration/test_routing_unification.py` | 14 | Chạy `RoutingApplicationService` trên **engine `core/routing` thật**; 3 surface (cowork/co4e/ai_edit) cho ra cùng 1 quyết định |
|
||||||
|
|
||||||
|
**Kết quả cổng kiểm duyệt (DoD 7 tiêu chí):**
|
||||||
|
|
||||||
|
| # | Tiêu chí | Lệnh | Kết quả |
|
||||||
|
| :---: | :--- | :--- | :--- |
|
||||||
|
| 1 | LOC < 400 | `wc -l` các file mới | ✅ Lớn nhất 288 dòng (`usage_sink.py`); `openai_compat.py` 374, `anthropic.py` 332 |
|
||||||
|
| 2 | Clean Architecture | `python scripts/check_imports.py` | ✅ `[PASS] 0 forbidden imports detected` |
|
||||||
|
| 3 | Comment tiếng Anh | Review thủ công | ✅ 100% khối code mới/sửa có comment giải thích logic + lý do kiến trúc |
|
||||||
|
| 4 | Có test tự động | `pytest tests/unit tests/contracts tests/integration` | ✅ 134 test mới, pass 100% |
|
||||||
|
| 5 | No Regression | `pytest tests/` | ✅ **236 passed in ~2.0s** (nền trước R03: 102 passed) |
|
||||||
|
| 6 | Timestamps | Bảng trên | ✅ Đã ghi Start/End cho T01–T06 |
|
||||||
|
| 7 | CASAN Gate | `scripts/run_quality_gate.py` | ⚠️ Script **chưa tồn tại** — thuộc R10-T02 (chưa làm). Đã chạy thay bằng `check_imports.py` + `pytest tests/` |
|
||||||
|
|
||||||
|
**Ghi chú kỹ thuật cần biết khi review:**
|
||||||
|
|
||||||
|
1. **Đã sửa 1 lỗi hạ tầng test có thể gây kết quả sai lệch**: `tests/conftest.py` cũ đẩy thư mục **cha** của repo vào `sys.path`, nên `import cowork_local.*` (dùng bởi `tests/routing/*` và `tests/characterization/*`) trỏ sang **một checkout `cowork_local` khác** nằm cạnh thư mục làm việc — test vẫn báo xanh nhưng chạy trên mã nguồn khác. Nay conftest bind thẳng checkout hiện tại vào `sys.modules["cowork_local"]`.
|
||||||
|
2. **Chế độ Fallback** là chế độ *chống gãy*, không phải chế độ tối ưu: giữ nguyên model người dùng chọn kể cả khi có model điểm cao hơn, chỉ chuyển khi model đó **không phục vụ được** turn (không có trong ranking / unavailable / probe fail). Engine `core/routing` không cần biết chế độ này — service map Fallback ➔ Auto khi hỏi ranking rồi tự áp luật chấp nhận riêng.
|
||||||
|
3. **T06 hiện tại**: provider publish `UsageEvent`; khi R04 dựng xong `AgentEvent` bus thì `ConversationApplicationService` sẽ là nơi phát sự kiện, sink giữ nguyên không phải sửa.
|
||||||
|
4. **Cần cài `mcp>=1.0.0`** (đã có trong `requirements.txt`) để `tests/test_project_context_mcp_template.py` collect được — thiếu gói này toàn bộ suite bị interrupt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,16 +135,22 @@
|
|||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
||||||
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
|
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
|
||||||
|
|
||||||
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-23 00:56` | End: `2026-08-23 01:00`*
|
||||||
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
|
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py` (+ `domain/agents/agent_event_codec.py` — shim dịch legacy dict, tách riêng để giữ LOC < 400 và để xoá gọn sau R08)
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-23 01:00` | End: `2026-08-23 01:08`*
|
||||||
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
|
- [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py` (+ `turn_runtime.py` định nghĩa 2 port/6 callable, `core_runtime_adapter.py` cầu nối sang `core/*`, `domain/agents/agent_result.py`)
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
*Start: `2026-08-23 01:08` | End: `2026-08-23 07:10`*
|
||||||
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
Chưa đổi call site nào — `run_cowork` giữ nguyên (Co4E vẫn dùng); việc chuyển call site là T04/T05. Bằng chứng tương đương: `tests/integration/test_conversation_service_parity.py` chạy cùng 1 script provider qua 2 đường và so khớp từng event/message/tool list trên 7 kịch bản.
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
||||||
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
*Start: `2026-08-23 07:10` | End: `2026-08-23 07:23`*
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
`build_job` không còn gọi `run_cowork`: nó chụp state widget tại submit time ➔ `build_cowork_turn_request()` (mới, `application/conversations/cowork_turn_request.py`) ➔ `ConversationApplicationService`. Thêm `combine_instructions()` vào `turn_runtime.py` (project context + admin agent, T05 dùng lại) và tham số `messages=` cho `execute()` để service append vào **đúng list của widget** — `_reattach_running_turn` đọc list đó trong lúc turn đang chạy và `_finalize_turn` slice nó sau đó. Kiểm chứng: `tests/integration/test_cowork_tab_turn.py` gọi thẳng `CoworkTab.build_job` (widget stub, không cần Qt) và chạy turn thật với `FakeProvider`.
|
||||||
|
⚠️ `ui/cowork_tab.py` 416 ➔ 455 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R08.
|
||||||
|
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
||||||
|
*Start: `2026-08-23 07:23` | End: `2026-08-23 07:31`*
|
||||||
|
Nhánh `task_type == "cowork"` của `_run_agent` gọi service thay vì `run_cowork`; 5 hành vi riêng của unattended run giữ nguyên (plan reminder, `history_ready`, autosave History mỗi `assistant_done`, timeout notice, `plan_incomplete_reason`). Tách `_unattended_prompt()` dùng `combine_instructions` để thứ tự reminder → skill → persona → prompt nằm ở 1 chỗ đọc được. Lưới an toàn: `tests/integration/test_task_executor_turn.py` viết **trước** khi migrate và pass 8/8 trên code cũ, vẫn pass sau khi migrate.
|
||||||
|
⚠️ `core/task_executors.py` 476 ➔ 524 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R07 (`application/scheduling/`).
|
||||||
|
Còn lại gọi `run_cowork`: `core/co4e_runner.py` (×2) và `ui/co4e_tab.py` — phân hệ Co4E của 🟣 Team Nam, R04 không chạm theo luật 1 file 1 team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -229,17 +289,21 @@
|
|||||||
|
|
||||||
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
||||||
| :--- | :--- | :---: | :---: | :---: |
|
| :--- | :--- | :---: | :---: | :---: |
|
||||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] |
|
||||||
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-22 18:45` | `2026-08-22 19:01` | [x] |
|
||||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-22 18:57` | [~] |
|
||||||
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||||
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||||
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||||
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-22 18:59` | [~] |
|
||||||
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||||
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||||
|
|
||||||
|
> **Chú thích trạng thái**: `[~]` = hoàn tất **phần thuộc EPIC R03**, phần còn lại của dòng đó thuộc EPIC khác nên chưa đóng.
|
||||||
|
> - Dòng **24/08**: đã xong `RoutingApplicationService` (R03-T03); phần `ComposerWidget`/`AttachmentPicker` thuộc R08-T01/T02 — chưa làm.
|
||||||
|
> - Dòng **28/08**: đã xóa copy routing trong `ui/chat_panel.py` (R03-T04) **và** cả `ui/co4e_tab.py`, `ui/folder_tab.py` (R03-T05); phần circular import `model_pricing` ↔ `usage_tracker` thuộc R09-T02 — chưa làm.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
|
### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# NHẬT KÝ THEO DÕI VÀ PHÒNG NGỪA LỖI TÁI CẤU TRÚC (BUG & LESSONS LEARNED LOG)
|
||||||
|
## DỰ ÁN: COWORK LOCAL (COWORK-LOCAL BAMBOO)
|
||||||
|
|
||||||
|
Tài liệu này dùng để ghi nhận **toàn bộ các lỗi, xung đột kiến trúc và sự cố phát sinh** trong suốt quá trình refactoring của cả 3 team (Team Duy, Team Nam, Team Hoa).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> ### 🛡️ NGUYÊN TẮC VÀNG VỀ QUẢN TRỊ CHẤT LƯỢNG (ZERO RECURRENCE):
|
||||||
|
> 1. **Ghi nhận ngay lập tức**: Khi gặp bất kỳ lỗi nào (Syntax, Circular Import, Type Error, Test Failure, Thread Freeze, Data Corruption), kỹ sư/AI phải ghi ngay vào tài liệu này trước khi tiếp tục task.
|
||||||
|
> 2. **Phân tích nguyên nhân gốc rễ (Root Cause)**: Không chỉ sửa phần ngọn mà phải giải thích rõ bản chất vì sao lỗi xảy ra.
|
||||||
|
> 3. **Rút ra quy tắc phòng ngừa (Prevention Rule)**: Đặt ra nguyên tắc kỹ thuật để **TUYỆT ĐỐI KHÔNG TÁI PHẠM** ở các task tiếp theo.
|
||||||
|
> 4. **Checklist đầu vào**: Trước khi bắt đầu bất kỳ task mới nào, kỹ sư/AI **bắt buộc phải đọc lại toàn bộ file này**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 BẢNG TỔNG HỢP CÁC LỖI ĐÃ PHÁT HIỆN & KHẮC PHỤC
|
||||||
|
|
||||||
|
| Bug ID | Ngày Phát Hiện | Phân Hệ / File Bị Ảnh Hưởng | Loại Lỗi | Trạng Thái | Team Phụ Trách |
|
||||||
|
| :--- | :---: | :--- | :--- | :---: | :---: |
|
||||||
|
| `BUG-001` | 2026-08-20 | `core/model_pricing.py` ↔ `core/usage_tracker.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Duy & Team Nam |
|
||||||
|
| `BUG-002` | 2026-08-20 | `core/agent_security.py` ↔ `core/agent_security_alert.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Nam |
|
||||||
|
| `BUG-003` | 2026-08-20 | `state.py::active_project_id` & `ui/workspace_tab.py` | Race Condition / Global State Leak | 🟡 Đã có giải pháp (R06) | Team Hoa |
|
||||||
|
| `BUG-004` | 2026-08-20 | `core/task_scheduler.py` ↔ `PySide6.QtCore.QTimer` | Architecture Violation (Qt in Domain/App) | 🟡 Đã có giải pháp (R07) | Team Hoa |
|
||||||
|
| `BUG-005` | 2026-08-20 | `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Code Duplication (Copy Routing Logic) | 🟡 Đã có giải pháp (R03) | Team Duy |
|
||||||
|
| `BUG-006` | 2026-08-21 | `scripts/check_imports.py` | UnicodeEncodeError (Windows CP932 console emoji) | 🟢 Đã khắc phục (R01) | Team Duy |
|
||||||
|
| `BUG-007` | 2026-08-21 | `platform/` ➔ `infrastructure/platform/` | Standard Library Shadowing (`import platform`) | 🟢 Đã khắc phục (R01) | Team Duy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 CHI TIẾT TỪNG LỖI & QUY TẮC PHÒNG NGỪA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 `BUG-001`: Circular Import giữa Module Định Giá (`model_pricing.py`) và Theo Dõi Token (`usage_tracker.py`)
|
||||||
|
|
||||||
|
* **Phân hệ**: `core/model_pricing.py` & `core/usage_tracker.py`
|
||||||
|
* **Triệu chứng (Symptom)**: Lỗi `ImportError: cannot import name 'ModelPricing' from partially initialized module` khi khởi động ứng dụng hoặc chạy test độc lập.
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- `model_pricing.py` import `UsageTracker` để cập nhật dữ liệu tiêu thụ.
|
||||||
|
- Ngược lại, `usage_tracker.py` import `ModelPricing` để tính toán chi phí theo từng model ID.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Tách Data Transfer Object (DTO) `ModelPricing` sang tầng Domain thuần túy `domain/models/model_pricing.py`.
|
||||||
|
- Cả `model_pricing.py` và `usage_tracker.py` đều import DTO từ `domain/models/`, chuyển quan hệ thành 1 chiều (Dependency Inversion).
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Không bao giờ để 2 service hoặc 2 module nghiệp vụ import lẫn nhau. Mọi cấu trúc dữ liệu dùng chung (DTO/Value Object/Event) **phải được đặt tại tầng `domain/`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 `BUG-002`: Circular Import giữa An Ninh Agent (`agent_security.py`) và Cảnh Báo (`agent_security_alert.py`)
|
||||||
|
|
||||||
|
* **Phân hệ**: `core/agent_security.py` & `core/agent_security_alert.py`
|
||||||
|
* **Triệu chứng (Symptom)**: Lỗi khởi tạo vòng tròn khi runtime bắn ra alert sự kiện bảo mật.
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- Module security vừa kiểm tra policy vừa khởi tạo trực tiếp instance alert dialog, trong khi alert dialog lại import ngược lại rule security để hiển thị chi tiết mã lỗi.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Tách sự kiện cảnh báo thành Event DTO `SecurityAlertEvent` tại `domain/security/security_event.py`.
|
||||||
|
- Tầng Security chỉ phát ra Event (`emit_event`), tầng Presentation/UI tự lắng nghe Event để render Dialog.
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Logic an ninh và xử lý nghiệp vụ không bao giờ được gọi trực tiếp UI Dialog. Luôn giao tiếp thông qua cơ chế Event-Driven (`AgentEvent`, `SecurityEvent`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 `BUG-003`: Xung Đột Race Condition do Sử Dụng Biến Toàn Cục `active_project_id` trong `state.py`
|
||||||
|
|
||||||
|
* **Phân hệ**: `state.py`, `ui/workspace_tab.py`, Scheduled Task Runners
|
||||||
|
* **Triệu chứng (Symptom)**: Khi task scheduler chạy ngầm hoặc người dùng chuyển tab nhanh, file bị ghi nhầm vào thư mục dự án khác với dự án đang hiển thị trên màn hình.
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- Ứng dụng đọc và ghi trực tiếp vào biến toàn cục `AppContext.active_project_id` từ nhiều luồng khác nhau mà không có cơ chế snapshot ngữ cảnh.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Xóa bỏ việc đọc biến toàn cục. Mỗi lần khởi chạy turn hoặc task, tạo một snapshot bất biến `WorkspaceSession(project_id, root_path, allowed_paths)`.
|
||||||
|
- Luồng ngầm chỉ thao tác trên `WorkspaceSession` được truyền vào từ lúc khởi tạo.
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Tuyệt đối không dùng biến toàn cục (Global State / Singletons có trạng thái thay đổi) để điều khiển luồng thực thi nền. Mọi ngữ cảnh phải được truyền tường minh qua DTO snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 `BUG-004`: Vi Phạm Ranh Giới Kiến Trúc Khi Import `PySide6.QtCore.QTimer` trong Domain / Scheduling Engine
|
||||||
|
|
||||||
|
* **Phân hệ**: `core/task_scheduler.py#L20`
|
||||||
|
* **Triệu chứng (Symptom)**: Không thể viết Unit Test cho thuật toán tính toán lịch chạy (cron/interval) trên môi trường CI/CD (GitHub Actions / Linux Server headless) nếu thiếu driver màn hình X11/Wayland hoặc chưa cài `PySide6`.
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- Động cơ lập lịch bị gắn chặt cứng với `QTimer` của framework Qt thay vì tách riêng logic tính toán thời gian.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Tách thuật toán tính lịch sang `domain/tasks/schedule_calculator.py` (Pure Python 100%).
|
||||||
|
- Tạo `platform/qt/qt_scheduler_clock.py` làm adapter bọc `QTimer` cho app chạy thật, và `tests/fakes/fake_clock.py` cho unit test.
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Tầng Domain và Application tuyệt đối không import thư viện GUI (`PySide6`, `PyQt`). Luôn bọc các thành phần phụ thuộc framework bên ngoài qua Adapter Interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 `BUG-005`: Nhân Bản Mã Nguồn (Code Duplication) Logic Routing Mô Hình AI tại Nhiều Màn Hình
|
||||||
|
|
||||||
|
* **Phân hệ**: `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`
|
||||||
|
* **Triệu chứng (Symptom)**: Khi cập nhật thêm model provider mới (như FPT Gateway hay Claude 3.7), phải sửa code thủ công ở 3 file UI khác nhau; phát sinh sai lệch quy tắc fallback giữa các màn hình.
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- Thiếu một tầng Application Service tập trung, dẫn đến việc lập trình viên copy-paste hàm chọn model từ `ChatPanel` sang các tab khác.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Xây dựng `application/model_routing/routing_application_service.py` duy nhất, cung cấp API `route_request(request) -> ModelRouteDecision`.
|
||||||
|
- Mọi màn hình UI chỉ gọi service này, không tự viết lại logic kiểm tra key hay fallback.
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Nghiệp vụ dùng chung giữa các màn hình phải được đưa vào `application/` services. Không bao giờ viết logic nghiệp vụ trực tiếp trong các file Widget UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 `BUG-006`: `UnicodeEncodeError` khi in Emojis trên Console Windows (CP932/CP1252)
|
||||||
|
|
||||||
|
* **Phân hệ / File**: `scripts/check_imports.py`
|
||||||
|
* **Triệu chứng (Symptom)**:
|
||||||
|
```text
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "scripts/check_imports.py", line 127, in main
|
||||||
|
print(f"\U0001f6e1\ufe0f Running Clean Architecture Import Guard...")
|
||||||
|
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f6e1' in position 0: illegal multibyte sequence
|
||||||
|
```
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- Trên hệ điều hành Windows sử dụng locale tiếng Nhật (mã trang CP932) hoặc tiếng Anh (CP1252), `sys.stdout` mặc định không hỗ trợ các ký tự Unicode/Emoji ngoài bảng mã, dẫn đến crash khi in log dòng lệnh.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Tự động bọc lại `sys.stdout` và `sys.stderr` bằng `io.TextIOWrapper` với `encoding="utf-8"` và `errors="replace"`.
|
||||||
|
- Thay thế các emoji phức tạp bằng các tag văn bản ASCII chuẩn hóa như `[Clean Arch Guard]`, `[PASS]`, `[FAIL]`.
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Mọi script CLI (`scripts/*.py`) phải có cơ chế cấu hình `utf-8` stream wrapper và ưu tiên sử dụng text tags (`[INFO]`, `[WARN]`, `[ERROR]`) thay vì emoji Unicode trực tiếp để đảm bảo chạy mượt mà trên mọi môi trường Windows đa ngôn ngữ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 `BUG-007`: Xung Đột Tên Thư Mục Trùng Với Standard Library (`platform/` Shadowing `import platform`)
|
||||||
|
|
||||||
|
* **Phân hệ / File**: `platform/` ➔ Chuyển thành `infrastructure/platform/`
|
||||||
|
* **Triệu chứng (Symptom)**:
|
||||||
|
```text
|
||||||
|
INTERNALERROR> File "_pytest/terminal.py", line 853: verinfo = platform.python_version()
|
||||||
|
INTERNALERROR> AttributeError: module 'platform' has no attribute 'python_version'
|
||||||
|
```
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**:
|
||||||
|
- Khi tạo một package ở thư mục gốc có tên trùng với module thư viện chuẩn của Python (`platform`, `email`, `test`, `asyncio`, `logging`), Python trên `sys.path` sẽ ưu tiên import thư mục local thay vì thư viện chuẩn của Python runtime, dẫn đến crash toàn bộ pytest runner và các thư viện bên thứ ba.
|
||||||
|
* **Giải pháp khắc phục (Resolution)**:
|
||||||
|
- Xóa bỏ package `platform/` ở root.
|
||||||
|
- Đưa adapter Qt Scheduler Clock vào đúng vị trí hạ tầng: `infrastructure/platform/qt/`.
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: Tuyệt đối không đặt tên package/thư mục ở root trùng với tên các module built-in của Python (`platform`, `logging`, `types`, `time`, `io`, `os`, `sys`). Mọi platform adapter phải nằm trong `infrastructure/platform/` hoặc `platform_adapters/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 MẪU GHI NHẬN BUG MỚI (BUG REPORT TEMPLATE)
|
||||||
|
|
||||||
|
Khi gặp bất kỳ bug mới nào trong quá trình làm việc, hãy sao chép khối mẫu sau và điền vào cuối tài liệu:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 🔴 `BUG-XXX`: [Tóm tắt ngắn gọn tên lỗi]
|
||||||
|
|
||||||
|
* **Phân hệ / File**: `[Đường dẫn file bị lỗi]`
|
||||||
|
* **Triệu chứng (Symptom)**: `[Mô tả hiện tượng lỗi, paste thông báo traceback hoặc kết quả test fail]`
|
||||||
|
* **Nguyên nhân gốc rễ (Root Cause)**: `[Giải thích tại sao lỗi lại xảy ra]`
|
||||||
|
* **Giải pháp khắc phục (Resolution)**: `[Mô tả cách sửa, file DTO/Service tạo mới hoặc cách refactor]`
|
||||||
|
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
|
||||||
|
> **Quy tắc**: `[Nguyên tắc kỹ thuật cụ thể để không bao giờ tái phạm lỗi này]`
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
|
||||||
@@ -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,110 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
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:
|
||||||
|
return PolicyDecision(PolicyOutcome.ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||||
|
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
|
||||||
|
|
||||||
|
|
||||||
|
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||||
|
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 @@
|
|||||||
|
"""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."""
|
||||||
@@ -583,10 +583,13 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
|
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
|
||||||
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
|
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
|
||||||
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
|
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
|
||||||
|
# Fallback (R03-T03): resilience mode -- never switches for a better
|
||||||
|
# score, only to rescue a selected model that cannot serve the turn.
|
||||||
|
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
|
||||||
"routing.toggle_tooltip": {
|
"routing.toggle_tooltip": {
|
||||||
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.",
|
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.",
|
||||||
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。",
|
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。",
|
||||||
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.",
|
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.",
|
||||||
},
|
},
|
||||||
"routing.confirm_title": {
|
"routing.confirm_title": {
|
||||||
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
|
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure config package: ConfigRepository and typed settings facades."""
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
|
||||||
|
|
||||||
|
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
|
||||||
|
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
|
||||||
|
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
|
||||||
|
đầu ngồi không.
|
||||||
|
|
||||||
|
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
|
||||||
|
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
|
||||||
|
thật, xếp theo số lần gọi.
|
||||||
|
|
||||||
|
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
|
||||||
|
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
|
||||||
|
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ConfigRepository(Protocol):
|
||||||
|
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
|
||||||
|
|
||||||
|
# ---- provider ------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def active_provider(self) -> str:
|
||||||
|
"""Tên provider đang chọn (24 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_active_provider(self, name: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||||
|
"""Cấu hình của một provider (9 lời gọi).
|
||||||
|
|
||||||
|
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
|
||||||
|
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
|
||||||
|
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- đường dẫn -----------------------------------------------------
|
||||||
|
@property
|
||||||
|
def shared_dir(self) -> str:
|
||||||
|
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def history_dir(self) -> Path:
|
||||||
|
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cowork_output_dir(self) -> Path:
|
||||||
|
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- giao diện -----------------------------------------------------
|
||||||
|
@property
|
||||||
|
def theme(self) -> str:
|
||||||
|
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_theme(self, value: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language(self) -> str:
|
||||||
|
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_language(self, value: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- các nhóm cấu hình còn lại -------------------------------------
|
||||||
|
@property
|
||||||
|
def routing(self) -> Dict[str, Any]:
|
||||||
|
"""Cấu hình định tuyến model (7 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth(self) -> Dict[str, Any]:
|
||||||
|
"""Cấu hình đăng nhập (6 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agent_security(self) -> Dict[str, Any]:
|
||||||
|
"""Chính sách an toàn cho agent (5 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tools_disabled(self) -> list[str]:
|
||||||
|
"""Tool bị tắt (2 lời gọi)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- ghi ------------------------------------------------------------
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
|
||||||
|
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""ConfigRepository chạy trên file JSON — R02-T02.
|
||||||
|
|
||||||
|
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
|
||||||
|
đều là thứ ta muốn:
|
||||||
|
|
||||||
|
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
|
||||||
|
cấu hình (R02-T01).
|
||||||
|
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
|
||||||
|
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
|
||||||
|
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
|
||||||
|
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
|
||||||
|
|
||||||
|
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
|
||||||
|
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
|
||||||
|
nhìn từ ngoài phải y hệt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ..persistence.json.atomic_json_file import AtomicJsonFile
|
||||||
|
from ..secrets.secret_store import SecretStore, provider_key
|
||||||
|
from .schema_migration import CURRENT_VERSION, migrate
|
||||||
|
|
||||||
|
|
||||||
|
class JsonConfigRepository:
|
||||||
|
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
|
||||||
|
|
||||||
|
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
|
||||||
|
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
|
||||||
|
(R02-T05) chứ không phải đổi một phát cả app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
|
||||||
|
defaults: Dict[str, Any] | None = None,
|
||||||
|
env_overrides=None):
|
||||||
|
self._file = AtomicJsonFile(path)
|
||||||
|
self._secrets = secrets
|
||||||
|
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
|
||||||
|
if defaults is None or env_overrides is None:
|
||||||
|
from ... import config as legacy
|
||||||
|
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
|
||||||
|
env_overrides = env_overrides or legacy._apply_env_overrides
|
||||||
|
self._defaults = defaults
|
||||||
|
self._env_overrides = env_overrides
|
||||||
|
self.data: Dict[str, Any] = self._load()
|
||||||
|
|
||||||
|
# ---- nạp ------------------------------------------------------------
|
||||||
|
def _load(self) -> Dict[str, Any]:
|
||||||
|
merged = copy.deepcopy(self._defaults)
|
||||||
|
stored = self._file.read(default=None)
|
||||||
|
if isinstance(stored, dict):
|
||||||
|
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
|
||||||
|
# đĩa, mà mặc định thì không có khoá nào để gỡ.
|
||||||
|
stored, changed = migrate(stored, secrets=self._secrets,
|
||||||
|
path=self._file.path)
|
||||||
|
merged = _deep_merge(merged, stored)
|
||||||
|
if changed:
|
||||||
|
self.data = merged
|
||||||
|
self.save() # ghi ngay, để lần sau khỏi chuyển lại
|
||||||
|
merged = self._env_overrides(merged)
|
||||||
|
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
||||||
|
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
||||||
|
merged.setdefault("ms365", {})["unlocked"] = False
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
self.data = self._load()
|
||||||
|
|
||||||
|
# ---- provider --------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def active_provider(self) -> str:
|
||||||
|
return self.data.get("active_provider", "")
|
||||||
|
|
||||||
|
def set_active_provider(self, name: str) -> None:
|
||||||
|
self.data["active_provider"] = name
|
||||||
|
|
||||||
|
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||||
|
"""Cấu hình provider, có sẵn ``api_key``.
|
||||||
|
|
||||||
|
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
|
||||||
|
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
|
||||||
|
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
|
||||||
|
"""
|
||||||
|
name = name or self.active_provider
|
||||||
|
conf = dict(self.data.get("providers", {}).get(name, {}))
|
||||||
|
if self._secrets is not None:
|
||||||
|
stored = self._secrets.get(provider_key(name))
|
||||||
|
if stored:
|
||||||
|
conf["api_key"] = stored
|
||||||
|
return conf
|
||||||
|
|
||||||
|
def set_api_key(self, name: str, value: str) -> None:
|
||||||
|
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
|
||||||
|
|
||||||
|
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
|
||||||
|
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
|
||||||
|
"""
|
||||||
|
if self._secrets is not None:
|
||||||
|
self._secrets.set(provider_key(name), value)
|
||||||
|
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
|
||||||
|
else:
|
||||||
|
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
|
||||||
|
|
||||||
|
# ---- đường dẫn -------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def shared_dir(self) -> str:
|
||||||
|
return self.data.get("shared_dir", "")
|
||||||
|
|
||||||
|
def history_dir(self) -> Path:
|
||||||
|
rt = self.data.get("_project_history_dir")
|
||||||
|
if rt:
|
||||||
|
return Path(rt)
|
||||||
|
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom).expanduser()
|
||||||
|
from ...config import CONFIG_DIR
|
||||||
|
return CONFIG_DIR / "history"
|
||||||
|
|
||||||
|
def cowork_output_dir(self) -> Path:
|
||||||
|
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom).expanduser()
|
||||||
|
from ... import paths
|
||||||
|
from ...config import CONFIG_DIR
|
||||||
|
root = paths.primary_onedrive_root()
|
||||||
|
if root is not None:
|
||||||
|
return root / "CoworkLocal" / "output"
|
||||||
|
return CONFIG_DIR / "output" / "cowork"
|
||||||
|
|
||||||
|
# ---- giao diện -------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def theme(self) -> str:
|
||||||
|
return self.data.get("theme", "dark")
|
||||||
|
|
||||||
|
def set_theme(self, value: str) -> None:
|
||||||
|
self.data["theme"] = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language(self) -> str:
|
||||||
|
return self.data.get("language", "vi")
|
||||||
|
|
||||||
|
def set_language(self, value: str) -> None:
|
||||||
|
self.data["language"] = value
|
||||||
|
|
||||||
|
# ---- nhóm cấu hình ---------------------------------------------------
|
||||||
|
@property
|
||||||
|
def routing(self) -> Dict[str, Any]:
|
||||||
|
return self.data.setdefault("routing", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth(self) -> Dict[str, Any]:
|
||||||
|
return self.data.setdefault("auth", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agent_security(self) -> Dict[str, Any]:
|
||||||
|
return self.data.setdefault("agent_security", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tools_disabled(self) -> list[str]:
|
||||||
|
return list(self.data.get("tools_disabled", []))
|
||||||
|
|
||||||
|
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||||
|
disabled = list(self.data.get("tools_disabled", []))
|
||||||
|
if enabled:
|
||||||
|
disabled = [t for t in disabled if t != name]
|
||||||
|
elif name not in disabled:
|
||||||
|
disabled.append(name)
|
||||||
|
self.data["tools_disabled"] = disabled
|
||||||
|
|
||||||
|
# ---- ghi -------------------------------------------------------------
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||||
|
to_write = self.data
|
||||||
|
if self.data.get("ms365", {}).get("unlocked"):
|
||||||
|
to_write = copy.deepcopy(self.data)
|
||||||
|
to_write["ms365"]["unlocked"] = False
|
||||||
|
to_write.pop("_project_history_dir", None)
|
||||||
|
to_write["schema_version"] = CURRENT_VERSION
|
||||||
|
self._file.write(to_write)
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
|
||||||
|
|
||||||
|
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
|
||||||
|
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
|
||||||
|
"""
|
||||||
|
out = copy.deepcopy(base)
|
||||||
|
for key, value in (override or {}).items():
|
||||||
|
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||||
|
out[key] = _deep_merge(out[key], value)
|
||||||
|
else:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
||||||
|
|
||||||
|
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
||||||
|
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
||||||
|
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
||||||
|
khoá ``office`` nghĩa là file cũ".
|
||||||
|
|
||||||
|
Ở đây đặt luật rõ:
|
||||||
|
|
||||||
|
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
||||||
|
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
||||||
|
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
||||||
|
file mới có thể hỏng — phải còn đường về.
|
||||||
|
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
||||||
|
đoán ngược.
|
||||||
|
|
||||||
|
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
||||||
|
``SecretStore``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict
|
||||||
|
|
||||||
|
from ..secrets.secret_store import SecretStore, provider_key
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Phiên bản app hiện đang ghi ra.
|
||||||
|
CURRENT_VERSION = 2
|
||||||
|
|
||||||
|
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
||||||
|
ASSUMED_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def read_version(data: Dict[str, Any]) -> int:
|
||||||
|
try:
|
||||||
|
return int(data.get("schema_version", ASSUMED_VERSION))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ASSUMED_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
||||||
|
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
||||||
|
|
||||||
|
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
||||||
|
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
||||||
|
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
||||||
|
"""
|
||||||
|
if secrets is None or not getattr(secrets, "available", True):
|
||||||
|
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
||||||
|
return data
|
||||||
|
|
||||||
|
out = copy.deepcopy(data)
|
||||||
|
moved = []
|
||||||
|
for name, conf in (out.get("providers") or {}).items():
|
||||||
|
if not isinstance(conf, dict):
|
||||||
|
continue
|
||||||
|
key = (conf.get("api_key") or "").strip()
|
||||||
|
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
||||||
|
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
||||||
|
if not key or key == "ollama":
|
||||||
|
continue
|
||||||
|
secrets.set(provider_key(name), key)
|
||||||
|
conf["api_key"] = ""
|
||||||
|
moved.append(name)
|
||||||
|
|
||||||
|
out["schema_version"] = 2
|
||||||
|
if moved:
|
||||||
|
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
||||||
|
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
||||||
|
1: _v1_to_v2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def backup(path: Path) -> Path | None:
|
||||||
|
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
||||||
|
try:
|
||||||
|
shutil.copy2(path, target)
|
||||||
|
return target
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("không sao lưu được %s: %s", path, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
||||||
|
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
||||||
|
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
||||||
|
|
||||||
|
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
||||||
|
khỏi phải ghi lại đĩa.
|
||||||
|
"""
|
||||||
|
version = read_version(data)
|
||||||
|
|
||||||
|
if version > CURRENT_VERSION:
|
||||||
|
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
||||||
|
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
||||||
|
version, CURRENT_VERSION)
|
||||||
|
return data, False
|
||||||
|
|
||||||
|
if version == CURRENT_VERSION:
|
||||||
|
return data, False
|
||||||
|
|
||||||
|
if path is not None:
|
||||||
|
backup(path)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
while version < CURRENT_VERSION:
|
||||||
|
step = STEPS.get(version)
|
||||||
|
if step is None:
|
||||||
|
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
||||||
|
break
|
||||||
|
data = step(data, secrets)
|
||||||
|
new_version = read_version(data)
|
||||||
|
if new_version <= version:
|
||||||
|
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
||||||
|
# keyring). Dừng, đừng lặp vô hạn.
|
||||||
|
break
|
||||||
|
version = new_version
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return data, changed
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
|
||||||
|
|
||||||
|
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
|
||||||
|
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
|
||||||
|
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
|
||||||
|
trong 29 file** kiểu đó.
|
||||||
|
|
||||||
|
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
|
||||||
|
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
|
||||||
|
chứ không phải mili giây.
|
||||||
|
|
||||||
|
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
|
||||||
|
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
|
||||||
|
dataclass thì lại sinh chuyện đồng bộ hai chiều.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class _View:
|
||||||
|
"""Khung nhìn lên một nhánh của dict cấu hình."""
|
||||||
|
|
||||||
|
def __init__(self, data: Dict[str, Any]):
|
||||||
|
self._d = data
|
||||||
|
|
||||||
|
def _get(self, key: str, default: Any) -> Any:
|
||||||
|
value = self._d.get(key, default)
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
|
def raw(self) -> Dict[str, Any]:
|
||||||
|
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
|
||||||
|
|
||||||
|
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
|
||||||
|
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
|
||||||
|
"""
|
||||||
|
return self._d
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderSettings(_View):
|
||||||
|
"""Một provider: đi đâu, model nào, khoá nào.
|
||||||
|
|
||||||
|
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
|
||||||
|
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return str(self._get("base_url", ""))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
return str(self._get("model", ""))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_key(self) -> str:
|
||||||
|
return str(self._get("api_key", ""))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
"""Đủ thông tin để gọi được chưa.
|
||||||
|
|
||||||
|
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
|
||||||
|
"có base_url và model", không phải "có api_key".
|
||||||
|
"""
|
||||||
|
return bool(self.base_url and self.model)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingSettings(_View):
|
||||||
|
"""Định tuyến model tự động (``core/routing/``)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def switch_mode(self) -> str:
|
||||||
|
"""``"off"`` | ``"auto"`` | ``"manual"``."""
|
||||||
|
return str(self._get("switch_mode", "off"))
|
||||||
|
|
||||||
|
@switch_mode.setter
|
||||||
|
def switch_mode(self, value: str) -> None:
|
||||||
|
self._d["switch_mode"] = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.switch_mode != "off"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def policy(self) -> str:
|
||||||
|
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
|
||||||
|
return str(self._get("policy", "balanced"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_score_gain(self) -> float:
|
||||||
|
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
|
||||||
|
return float(self._get("min_score_gain", 0.05))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confirm_timeout_sec(self) -> int:
|
||||||
|
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
|
||||||
|
return int(self._get("confirm_timeout_sec", 60))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reassess_interval_hours(self) -> int:
|
||||||
|
return int(self._get("reassess_interval_hours", 24))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def per_provider_concurrency(self) -> int:
|
||||||
|
return int(self._get("per_provider_concurrency", 2))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def judge_provider(self) -> str:
|
||||||
|
return str(self._get("judge_provider", ""))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def judge_model(self) -> str:
|
||||||
|
return str(self._get("judge_model", ""))
|
||||||
|
|
||||||
|
|
||||||
|
class SecuritySettings(_View):
|
||||||
|
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self._get("enabled", True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validate_prompt(self) -> bool:
|
||||||
|
return bool(self._get("validate_prompt", True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validate_attachments(self) -> bool:
|
||||||
|
return bool(self._get("validate_attachments", True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validate_commands(self) -> bool:
|
||||||
|
return bool(self._get("validate_commands", True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def command_ai_check(self) -> bool:
|
||||||
|
return bool(self._get("command_ai_check", False))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cowork_confirm_commands(self) -> bool:
|
||||||
|
"""Có hỏi trước khi chạy lệnh không.
|
||||||
|
|
||||||
|
Ứng với ``PolicyOutcome.ASK`` trong
|
||||||
|
``domain/security/tool_policy.py``.
|
||||||
|
"""
|
||||||
|
return bool(self._get("cowork_confirm_commands", True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rules_onedrive_url(self) -> str:
|
||||||
|
return str(self._get("rules_onedrive_url", ""))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_email(self) -> str:
|
||||||
|
return str(self._get("admin_email", ""))
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
|
||||||
|
|
||||||
|
>>> s = Settings(repo)
|
||||||
|
>>> if s.routing.enabled and s.provider().configured:
|
||||||
|
... ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, repo):
|
||||||
|
self._repo = repo
|
||||||
|
|
||||||
|
def provider(self, name: str | None = None) -> ProviderSettings:
|
||||||
|
return ProviderSettings(self._repo.provider_conf(name))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routing(self) -> RoutingSettings:
|
||||||
|
return RoutingSettings(self._repo.routing)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def security(self) -> SecuritySettings:
|
||||||
|
return SecuritySettings(self._repo.agent_security)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure filesystem package: Tool handlers (file, command, fetch tools) and execution workspace."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure MCP package: McpToolSourceManager and child process lifecycle."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure persistence package."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories."""
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
||||||
|
|
||||||
|
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
||||||
|
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
||||||
|
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
||||||
|
— và người dùng mất toàn bộ cấu hình.
|
||||||
|
|
||||||
|
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
||||||
|
|
||||||
|
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
||||||
|
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
||||||
|
bộ đệm của hệ điều hành
|
||||||
|
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
||||||
|
|
||||||
|
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
||||||
|
bao giờ có trạng thái ghi dở.
|
||||||
|
|
||||||
|
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
||||||
|
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
||||||
|
đúng như ``config.py`` hiện tại đang làm.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AtomicJsonFile:
|
||||||
|
"""Một file JSON, đọc ghi an toàn.
|
||||||
|
|
||||||
|
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
||||||
|
>>> f.write({"theme": "dark"})
|
||||||
|
>>> f.read(default={})
|
||||||
|
{'theme': 'dark'}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, *, indent: int = 2):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.indent = indent
|
||||||
|
|
||||||
|
# ---- đọc ------------------------------------------------------------
|
||||||
|
def read(self, default: Any = None) -> Any:
|
||||||
|
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
||||||
|
|
||||||
|
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
||||||
|
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
||||||
|
"""
|
||||||
|
if not self.path.exists():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
self._quarantine()
|
||||||
|
return default
|
||||||
|
except OSError:
|
||||||
|
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
||||||
|
# có thể vẫn tốt nguyên.
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _quarantine(self) -> Path | None:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
||||||
|
try:
|
||||||
|
os.replace(self.path, target)
|
||||||
|
return target
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- ghi ------------------------------------------------------------
|
||||||
|
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
|
||||||
|
_REPLACE_TRIES = 6
|
||||||
|
_REPLACE_BACKOFF = 0.02
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
|
||||||
|
"""``os.replace`` có thử lại — bắt buộc trên Windows.
|
||||||
|
|
||||||
|
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
|
||||||
|
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
|
||||||
|
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
|
||||||
|
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
|
||||||
|
|
||||||
|
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
|
||||||
|
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
|
||||||
|
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
|
||||||
|
|
||||||
|
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
|
||||||
|
"""
|
||||||
|
for lan in range(cls._REPLACE_TRIES):
|
||||||
|
try:
|
||||||
|
os.replace(src, dst)
|
||||||
|
return
|
||||||
|
except PermissionError:
|
||||||
|
if lan == cls._REPLACE_TRIES - 1:
|
||||||
|
raise
|
||||||
|
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
|
||||||
|
|
||||||
|
def write(self, data: Any) -> None:
|
||||||
|
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
||||||
|
|
||||||
|
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
||||||
|
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
||||||
|
# thành copy + delete — mất luôn tính nguyên tử.
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||||
|
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
|
||||||
|
except BaseException:
|
||||||
|
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||||
|
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ---- tiện ích -------------------------------------------------------
|
||||||
|
def exists(self) -> bool:
|
||||||
|
return self.path.exists()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AtomicJsonFile({self.path})"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure platform adapters package."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure providers package: LLM provider adapters and ProviderRegistry."""
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Central registry of every LLM provider the app can talk to.
|
||||||
|
|
||||||
|
Replaces the bare ``{name: class}`` dict in ``providers/factory.py`` as the
|
||||||
|
single catalogue of providers. Two responsibilities, kept deliberately narrow:
|
||||||
|
|
||||||
|
1. **Lookup** — resolve a provider id (or one of its aliases, or a bare model
|
||||||
|
id) to its :class:`~domain.models.provider_descriptor.ProviderDescriptor`.
|
||||||
|
2. **Construction** — instantiate the concrete adapter class that speaks the
|
||||||
|
descriptor's wire protocol.
|
||||||
|
|
||||||
|
This is infrastructure, not domain: it is allowed to import the concrete
|
||||||
|
``providers/*`` adapters (which pull in ``requests``). The adapters are imported
|
||||||
|
lazily inside :meth:`build` so that merely *reading the catalogue* — which the
|
||||||
|
pure routing service does on every turn — never drags the HTTP stack into the
|
||||||
|
process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from ...domain.models.provider_descriptor import (
|
||||||
|
AuthKind,
|
||||||
|
ProviderDescriptor,
|
||||||
|
WireProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Built-in catalogue.
|
||||||
|
#
|
||||||
|
# Mirrors DEFAULT_CONFIG["providers"] in config.py (ids + default models) and
|
||||||
|
# providers/factory.py (id -> wire protocol). Prices are intentionally absent:
|
||||||
|
# core/routing/metadata.py owns cost, and a guessed price is worse than a
|
||||||
|
# known-unknown (see that module's docstring).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
BUILTIN_DESCRIPTORS: tuple = (
|
||||||
|
ProviderDescriptor(
|
||||||
|
provider_id="openai_compat",
|
||||||
|
display_name="OpenAI-compatible gateway",
|
||||||
|
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||||
|
auth_kind=AuthKind.API_KEY,
|
||||||
|
default_model="gpt-4o-mini",
|
||||||
|
supports_vision=True,
|
||||||
|
# A generic gateway has no fixed host, so the endpoint MUST be
|
||||||
|
# configured before the provider can be used at all.
|
||||||
|
requires_base_url=True,
|
||||||
|
),
|
||||||
|
ProviderDescriptor(
|
||||||
|
provider_id="anthropic",
|
||||||
|
display_name="Anthropic Claude",
|
||||||
|
wire_protocol=WireProtocol.ANTHROPIC,
|
||||||
|
auth_kind=AuthKind.API_KEY,
|
||||||
|
default_model="claude-sonnet-4-6",
|
||||||
|
# Kept in sync with AnthropicProvider._FALLBACK_MODELS — the list the
|
||||||
|
# provider itself falls back to when /v1/models cannot be reached.
|
||||||
|
models=("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
|
||||||
|
max_context=200000,
|
||||||
|
supports_vision=True,
|
||||||
|
),
|
||||||
|
ProviderDescriptor(
|
||||||
|
provider_id="ollama",
|
||||||
|
display_name="Ollama (local)",
|
||||||
|
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||||
|
# A local runtime needs no credential; Settings must not demand one.
|
||||||
|
auth_kind=AuthKind.NONE,
|
||||||
|
default_model="llama3.1",
|
||||||
|
supports_vision=False,
|
||||||
|
requires_base_url=True,
|
||||||
|
),
|
||||||
|
ProviderDescriptor(
|
||||||
|
provider_id="github_copilot",
|
||||||
|
display_name="GitHub Copilot",
|
||||||
|
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||||
|
# The credential is a Copilot token minted by an external login flow,
|
||||||
|
# not a self-service API key.
|
||||||
|
auth_kind=AuthKind.OAUTH_TOKEN,
|
||||||
|
default_model="gpt-4o",
|
||||||
|
models=("gpt-4o", "gpt-4o-mini"),
|
||||||
|
max_context=128000,
|
||||||
|
supports_vision=True,
|
||||||
|
),
|
||||||
|
ProviderDescriptor(
|
||||||
|
provider_id="codex",
|
||||||
|
display_name="OpenAI",
|
||||||
|
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
||||||
|
auth_kind=AuthKind.API_KEY,
|
||||||
|
default_model="gpt-4o-mini",
|
||||||
|
models=("gpt-4o", "gpt-4o-mini", "o1", "o3"),
|
||||||
|
max_context=128000,
|
||||||
|
supports_vision=True,
|
||||||
|
# Historic config key: early builds stored this provider as "openai".
|
||||||
|
aliases=("openai",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderNotFoundError(LookupError):
|
||||||
|
"""Raised when no descriptor answers to the requested provider id.
|
||||||
|
|
||||||
|
A dedicated type (rather than bare ``KeyError``) lets callers distinguish
|
||||||
|
"this provider is not in the catalogue" from an unrelated dict miss, and
|
||||||
|
keeps the message actionable by listing what IS registered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRegistry:
|
||||||
|
"""Thread-safe catalogue of :class:`ProviderDescriptor` records.
|
||||||
|
|
||||||
|
Thread-safety matters because model discovery runs on background worker
|
||||||
|
threads (the routing prober, Settings' "Load models") and republishes an
|
||||||
|
updated descriptor via :meth:`replace`, while chat turns on other threads
|
||||||
|
are reading the catalogue concurrently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
|
||||||
|
# Keyed by canonical id; alias resolution walks the values so an alias
|
||||||
|
# can never shadow a real provider id.
|
||||||
|
self._by_id: Dict[str, ProviderDescriptor] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
for descriptor in descriptors or ():
|
||||||
|
self.register(descriptor)
|
||||||
|
|
||||||
|
# -- registration --------------------------------------------------- #
|
||||||
|
def register(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
|
||||||
|
"""Add a descriptor. Refuses to silently overwrite an existing id so a
|
||||||
|
typo in a plugin cannot hijack a built-in provider; use :meth:`replace`
|
||||||
|
when an update is the actual intent."""
|
||||||
|
with self._lock:
|
||||||
|
existing = self._by_id.get(descriptor.provider_id)
|
||||||
|
if existing is not None and existing != descriptor:
|
||||||
|
raise ValueError(
|
||||||
|
f"Provider '{descriptor.provider_id}' is already registered; "
|
||||||
|
"call replace() to update it."
|
||||||
|
)
|
||||||
|
self._by_id[descriptor.provider_id] = descriptor
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
def replace(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
|
||||||
|
"""Register or update a descriptor unconditionally — the path model
|
||||||
|
discovery uses to publish a freshly enumerated model list."""
|
||||||
|
with self._lock:
|
||||||
|
self._by_id[descriptor.provider_id] = descriptor
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
# -- lookup ---------------------------------------------------------- #
|
||||||
|
def get(self, provider_id: str) -> ProviderDescriptor:
|
||||||
|
"""Descriptor for ``provider_id`` (canonical id or alias).
|
||||||
|
|
||||||
|
Raises :class:`ProviderNotFoundError` rather than returning ``None`` so
|
||||||
|
a misconfigured provider fails loudly at the call site instead of
|
||||||
|
surfacing later as an ``AttributeError`` on ``None``.
|
||||||
|
"""
|
||||||
|
found = self.find(provider_id)
|
||||||
|
if found is None:
|
||||||
|
known = ", ".join(sorted(self._by_id)) or "<empty registry>"
|
||||||
|
raise ProviderNotFoundError(
|
||||||
|
f"Unsupported provider: {provider_id!r}. Registered: {known}"
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
def find(self, provider_id: str) -> Optional[ProviderDescriptor]:
|
||||||
|
"""Non-raising :meth:`get` — ``None`` when nothing matches."""
|
||||||
|
needle = (provider_id or "").strip()
|
||||||
|
if not needle:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
direct = self._by_id.get(needle)
|
||||||
|
if direct is not None:
|
||||||
|
return direct
|
||||||
|
# Fall back to a case-insensitive id/alias scan; order is stable
|
||||||
|
# because dicts preserve insertion order, so the earliest-registered
|
||||||
|
# provider wins a tie.
|
||||||
|
for descriptor in self._by_id.values():
|
||||||
|
if descriptor.matches(needle):
|
||||||
|
return descriptor
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_by_model(self, model_id: str) -> Optional[ProviderDescriptor]:
|
||||||
|
"""Resolve a bare model id back to the provider that serves it.
|
||||||
|
|
||||||
|
This is the "dynamic lookup by model ID" R03-T02 calls for: routing
|
||||||
|
decisions and saved conversations sometimes carry only a model name, and
|
||||||
|
the caller still needs to know which provider to build. Returns ``None``
|
||||||
|
when the model belongs to a gateway whose catalogue we cannot enumerate
|
||||||
|
offline — callers then fall back to the configured active provider.
|
||||||
|
"""
|
||||||
|
needle = (model_id or "").strip()
|
||||||
|
if not needle:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
for descriptor in self._by_id.values():
|
||||||
|
if descriptor.knows_model(needle):
|
||||||
|
return descriptor
|
||||||
|
return None
|
||||||
|
|
||||||
|
def all(self) -> List[ProviderDescriptor]:
|
||||||
|
"""Every registered descriptor, in registration order (snapshot copy —
|
||||||
|
safe to iterate while another thread registers)."""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._by_id.values())
|
||||||
|
|
||||||
|
def ids(self) -> List[str]:
|
||||||
|
"""Canonical provider ids, sorted for stable UI/reporting output."""
|
||||||
|
with self._lock:
|
||||||
|
return sorted(self._by_id)
|
||||||
|
|
||||||
|
def __contains__(self, provider_id: object) -> bool:
|
||||||
|
return isinstance(provider_id, str) and self.find(provider_id) is not None
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._by_id)
|
||||||
|
|
||||||
|
# -- construction ---------------------------------------------------- #
|
||||||
|
def adapter_class(self, provider_id: str):
|
||||||
|
"""Concrete ``Provider`` subclass implementing this provider's protocol.
|
||||||
|
|
||||||
|
The adapters are imported here (not at module import) so the pure
|
||||||
|
routing/domain code can consult the catalogue without loading
|
||||||
|
``requests`` and the whole HTTP stack.
|
||||||
|
"""
|
||||||
|
descriptor = self.get(provider_id)
|
||||||
|
from ...providers.anthropic import AnthropicProvider
|
||||||
|
from ...providers.openai_compat import OpenAICompatProvider
|
||||||
|
|
||||||
|
protocol_to_class = {
|
||||||
|
WireProtocol.OPENAI_COMPAT: OpenAICompatProvider,
|
||||||
|
WireProtocol.ANTHROPIC: AnthropicProvider,
|
||||||
|
}
|
||||||
|
adapter = protocol_to_class.get(descriptor.wire_protocol)
|
||||||
|
if adapter is None: # pragma: no cover — unreachable while the map is total
|
||||||
|
raise ProviderNotFoundError(
|
||||||
|
f"No adapter implements wire protocol {descriptor.wire_protocol!r}"
|
||||||
|
)
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
def build(self, provider_id: str, conf: Dict[str, Any]):
|
||||||
|
"""Instantiate a ready-to-use provider adapter.
|
||||||
|
|
||||||
|
The descriptor's ``default_model`` fills in a missing/blank ``model`` so
|
||||||
|
a half-written config still produces a working provider instead of an
|
||||||
|
empty model id that only fails once the request hits the gateway.
|
||||||
|
"""
|
||||||
|
descriptor = self.get(provider_id)
|
||||||
|
adapter = self.adapter_class(descriptor.provider_id)
|
||||||
|
merged = dict(conf or {})
|
||||||
|
merged["model"] = descriptor.resolve_model(merged.get("model", ""))
|
||||||
|
return adapter(merged)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Process-wide default registry.
|
||||||
|
#
|
||||||
|
# Built lazily under a lock: several UI screens can ask for it during startup
|
||||||
|
# from different threads, and double-construction would hand out two catalogues
|
||||||
|
# whose discovered model lists then drift apart.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_default_registry: Optional[ProviderRegistry] = None
|
||||||
|
_default_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def default_registry() -> ProviderRegistry:
|
||||||
|
"""The shared registry seeded with :data:`BUILTIN_DESCRIPTORS`."""
|
||||||
|
global _default_registry
|
||||||
|
if _default_registry is None:
|
||||||
|
with _default_lock:
|
||||||
|
if _default_registry is None:
|
||||||
|
_default_registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||||
|
return _default_registry
|
||||||
|
|
||||||
|
|
||||||
|
def reset_default_registry() -> None:
|
||||||
|
"""Drop the cached registry — test-support hook so one test's registrations
|
||||||
|
cannot leak into the next."""
|
||||||
|
global _default_registry
|
||||||
|
with _default_lock:
|
||||||
|
_default_registry = None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BUILTIN_DESCRIPTORS",
|
||||||
|
"ProviderNotFoundError",
|
||||||
|
"ProviderRegistry",
|
||||||
|
"default_registry",
|
||||||
|
"reset_default_registry",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""SecretStore chạy trên OS Keyring — R02-T04.
|
||||||
|
|
||||||
|
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
|
||||||
|
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
|
||||||
|
``config.json`` — đó là điều kiện để qua CASAN Check 1.
|
||||||
|
|
||||||
|
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
|
||||||
|
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
|
||||||
|
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
|
||||||
|
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
|
||||||
|
SERVICE = "cowork-local"
|
||||||
|
|
||||||
|
|
||||||
|
class KeyringAdapter:
|
||||||
|
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
|
||||||
|
|
||||||
|
>>> store = KeyringAdapter()
|
||||||
|
>>> if store.available:
|
||||||
|
... store.set("provider:openai", "sk-...")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, service: str = SERVICE):
|
||||||
|
self.service = service
|
||||||
|
self._backend = None
|
||||||
|
self._available = False
|
||||||
|
try:
|
||||||
|
import keyring
|
||||||
|
from keyring.backends.fail import Keyring as FailKeyring
|
||||||
|
|
||||||
|
backend = keyring.get_keyring()
|
||||||
|
# backend "fail" là cái keyring trả về khi không tìm được kho nào
|
||||||
|
# dùng được — gọi vào chỉ tổ ném lỗi.
|
||||||
|
if not isinstance(backend, FailKeyring):
|
||||||
|
self._backend = keyring
|
||||||
|
self._available = True
|
||||||
|
else:
|
||||||
|
log.info("keyring không có kho khả dụng trên máy này")
|
||||||
|
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
|
||||||
|
log.info("keyring không dùng được: %s", exc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Có kho bí mật dùng được không.
|
||||||
|
|
||||||
|
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
|
||||||
|
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
|
||||||
|
"""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
# ---- SecretStore ----------------------------------------------------
|
||||||
|
def get(self, key: str) -> str | None:
|
||||||
|
if not self._available:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._backend.get_password(self.service, key)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("đọc khoá %r thất bại: %s", key, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: str) -> None:
|
||||||
|
if not self._available:
|
||||||
|
log.warning("không lưu được %r: máy này không có kho bí mật", key)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._backend.set_password(self.service, key, value)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("lưu khoá %r thất bại: %s", key, exc)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._backend.delete_password(self.service, key)
|
||||||
|
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
|
||||||
|
pass
|
||||||
|
|
||||||
|
def has(self, key: str) -> bool:
|
||||||
|
return self.get(key) is not None
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Nơi cất credential — interface, chưa phải cài đặt.
|
||||||
|
|
||||||
|
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
|
||||||
|
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
|
||||||
|
|
||||||
|
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
|
||||||
|
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
|
||||||
|
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
|
||||||
|
|
||||||
|
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
|
||||||
|
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
def provider_key(name: str) -> str:
|
||||||
|
"""Key chuẩn cho API key của một provider."""
|
||||||
|
return f"provider:{name}"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SecretStore(Protocol):
|
||||||
|
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
|
||||||
|
|
||||||
|
def get(self, key: str) -> str | None:
|
||||||
|
"""Giá trị của ``key``, hoặc None nếu chưa có.
|
||||||
|
|
||||||
|
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
|
||||||
|
dùng chưa nhập API key), không phải sự cố.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set(self, key: str, value: str) -> None:
|
||||||
|
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def has(self, key: str) -> bool:
|
||||||
|
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
|
||||||
|
cần đọc chính giá trị bí mật ra."""
|
||||||
|
...
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks."""
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Token-usage telemetry as a publish/subscribe seam (R03-T06).
|
||||||
|
|
||||||
|
Before this module every provider adapter reached straight into
|
||||||
|
``core/usage_tracker.py`` and wrote a dashboard row itself, which meant the
|
||||||
|
provider layer owned a telemetry policy decision ("where do usage numbers go?")
|
||||||
|
and no test could observe a turn's token accounting without touching the real
|
||||||
|
``~/.cowork_local/usage/`` files.
|
||||||
|
|
||||||
|
Now a provider only *describes what happened* — it publishes an immutable
|
||||||
|
:class:`UsageEvent` — and subscribers decide what to do with it. The default
|
||||||
|
subscriber, :class:`UsageTrackerSink`, forwards to the existing usage tracker so
|
||||||
|
the Dashboard keeps working byte-for-byte; tests swap in
|
||||||
|
:class:`InMemoryUsageSink` and assert on the events directly.
|
||||||
|
|
||||||
|
Every publish path is failure-tolerant on purpose: telemetry must never be the
|
||||||
|
reason a chat turn dies, which is the same contract
|
||||||
|
``usage_tracker.record()`` already documents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
logger = logging.getLogger("cowork_local.telemetry.usage")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UsageEvent:
|
||||||
|
"""One provider turn's token accounting.
|
||||||
|
|
||||||
|
Frozen so a subscriber cannot mutate an event the next subscriber in the
|
||||||
|
chain is about to receive. ``source``/``label`` stay optional: the usage
|
||||||
|
tracker already derives them from thread-local context set by whoever ran
|
||||||
|
the turn, and a provider adapter has no business knowing which UI surface
|
||||||
|
invoked it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
cached_tokens: int = 0
|
||||||
|
# True when the counts are a ~4-chars-per-token approximation because the
|
||||||
|
# gateway never sent a usage block. Surfaced in the Dashboard so users know
|
||||||
|
# which rows are measured and which are guessed.
|
||||||
|
estimated: bool = False
|
||||||
|
source: Optional[str] = None # None -> tracker's thread-local context
|
||||||
|
label: Optional[str] = None # None -> tracker's thread-local context
|
||||||
|
extras: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_tokens(self) -> int:
|
||||||
|
"""Billable token count for this turn (cached tokens are already part
|
||||||
|
of the input count reported by every gateway we support, so adding them
|
||||||
|
again would double-count)."""
|
||||||
|
return int(self.input_tokens) + int(self.output_tokens)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""JSON-friendly view, using the same short keys as the usage tracker's
|
||||||
|
on-disk rows so a caller can diff an event against a stored row."""
|
||||||
|
return {
|
||||||
|
"provider": self.provider,
|
||||||
|
"model": self.model,
|
||||||
|
"in": int(self.input_tokens),
|
||||||
|
"out": int(self.output_tokens),
|
||||||
|
"cache": int(self.cached_tokens),
|
||||||
|
"estimated": bool(self.estimated),
|
||||||
|
"source": self.source or "",
|
||||||
|
"label": self.label or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class UsageEventSink(Protocol):
|
||||||
|
"""Anything that can receive :class:`UsageEvent`s.
|
||||||
|
|
||||||
|
A ``Protocol`` rather than a base class so a plain object (or a test double,
|
||||||
|
or a Qt-side adapter that re-emits a signal) qualifies without inheriting
|
||||||
|
from infrastructure code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def emit(self, event: UsageEvent) -> None:
|
||||||
|
"""Handle one usage event. Implementations MUST NOT raise."""
|
||||||
|
|
||||||
|
|
||||||
|
class UsageTrackerSink:
|
||||||
|
"""Default subscriber: writes each event through ``core/usage_tracker.py``.
|
||||||
|
|
||||||
|
Keeps the existing Dashboard/telemetry pipeline (daily JSONL files, shared
|
||||||
|
cross-machine mirror, per-thread accumulator) as the single writer, so
|
||||||
|
routing this through an event seam changed the plumbing without changing
|
||||||
|
a single stored byte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, recorder=None) -> None:
|
||||||
|
# The recorder is injectable so a test can verify the forwarding
|
||||||
|
# contract without importing the real tracker (and its config paths).
|
||||||
|
self._recorder = recorder
|
||||||
|
|
||||||
|
def _resolve_recorder(self):
|
||||||
|
"""Late-bind ``usage_tracker.record``.
|
||||||
|
|
||||||
|
Imported on first use rather than at module import so telemetry stays
|
||||||
|
out of the import graph of anything that merely *declares* a sink.
|
||||||
|
"""
|
||||||
|
if self._recorder is None:
|
||||||
|
from ...core import usage_tracker as tracker
|
||||||
|
|
||||||
|
self._recorder = tracker.record
|
||||||
|
return self._recorder
|
||||||
|
|
||||||
|
def emit(self, event: UsageEvent) -> None:
|
||||||
|
"""Forward one event; swallow every failure (telemetry is never fatal)."""
|
||||||
|
try:
|
||||||
|
record = self._resolve_recorder()
|
||||||
|
if event.source is None:
|
||||||
|
# Normal path: the worker thread already tagged its own
|
||||||
|
# source/label via set_context(), so record() attributes the row.
|
||||||
|
record(
|
||||||
|
event.provider, event.model,
|
||||||
|
int(event.input_tokens), int(event.output_tokens),
|
||||||
|
int(event.cached_tokens), estimated=bool(event.estimated),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Event carries its own attribution: apply it for this single write
|
||||||
|
# and restore the thread's previous context afterwards, so a
|
||||||
|
# re-attributed event cannot silently relabel every later turn that
|
||||||
|
# runs on the same worker thread.
|
||||||
|
from ...core import usage_tracker as tracker
|
||||||
|
|
||||||
|
previous_source, previous_label = tracker.current_context()
|
||||||
|
tracker.set_context(event.source, event.label or "")
|
||||||
|
try:
|
||||||
|
record(
|
||||||
|
event.provider, event.model,
|
||||||
|
int(event.input_tokens), int(event.output_tokens),
|
||||||
|
int(event.cached_tokens), estimated=bool(event.estimated),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tracker.set_context(previous_source, previous_label)
|
||||||
|
except Exception: # noqa: BLE001 — usage tracking must never break a turn
|
||||||
|
logger.debug("usage sink: forwarding to usage_tracker failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryUsageSink:
|
||||||
|
"""Collects events in a list — the test double for usage assertions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.events: List[UsageEvent] = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def emit(self, event: UsageEvent) -> None:
|
||||||
|
"""Append under a lock: parallel Co4E flows publish from several worker
|
||||||
|
threads at once and ``list.append`` alone would still be atomic, but the
|
||||||
|
lock also makes :meth:`snapshot` a consistent read."""
|
||||||
|
with self._lock:
|
||||||
|
self.events.append(event)
|
||||||
|
|
||||||
|
def snapshot(self) -> List[UsageEvent]:
|
||||||
|
"""A copy of everything received so far."""
|
||||||
|
with self._lock:
|
||||||
|
return list(self.events)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.events.clear()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_tokens(self) -> int:
|
||||||
|
return sum(e.total_tokens for e in self.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeUsageSink:
|
||||||
|
"""Fans one event out to several subscribers.
|
||||||
|
|
||||||
|
This is what makes the seam useful beyond the Dashboard: a future consumer
|
||||||
|
(per-workspace budget guard, live cost meter) subscribes alongside the
|
||||||
|
tracker instead of patching provider code again. One failing subscriber is
|
||||||
|
logged and skipped so it cannot starve the others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sinks=None) -> None:
|
||||||
|
self._sinks: List[UsageEventSink] = list(sinks or ())
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def add(self, sink: UsageEventSink) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._sinks.append(sink)
|
||||||
|
|
||||||
|
def remove(self, sink: UsageEventSink) -> None:
|
||||||
|
"""Detach a subscriber; a sink that was never added is ignored so
|
||||||
|
teardown code can call this unconditionally."""
|
||||||
|
with self._lock:
|
||||||
|
if sink in self._sinks:
|
||||||
|
self._sinks.remove(sink)
|
||||||
|
|
||||||
|
def sinks(self) -> List[UsageEventSink]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._sinks)
|
||||||
|
|
||||||
|
def emit(self, event: UsageEvent) -> None:
|
||||||
|
for sink in self.sinks():
|
||||||
|
try:
|
||||||
|
sink.emit(event)
|
||||||
|
except Exception: # noqa: BLE001 — one bad subscriber must not stop the rest
|
||||||
|
logger.debug("usage sink: subscriber %r failed", sink, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Process-wide sink.
|
||||||
|
#
|
||||||
|
# Providers publish through the module-level helpers below rather than holding a
|
||||||
|
# sink reference, because a provider instance is created fresh for every turn
|
||||||
|
# (see AppContext.build_provider_for) and would otherwise have to be handed the
|
||||||
|
# telemetry wiring on every construction.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_sink_lock = threading.RLock()
|
||||||
|
_sink: Optional[CompositeUsageSink] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_usage_sink() -> CompositeUsageSink:
|
||||||
|
"""The shared sink, seeded with :class:`UsageTrackerSink` on first use."""
|
||||||
|
global _sink
|
||||||
|
if _sink is None:
|
||||||
|
with _sink_lock:
|
||||||
|
if _sink is None:
|
||||||
|
_sink = CompositeUsageSink([UsageTrackerSink()])
|
||||||
|
return _sink
|
||||||
|
|
||||||
|
|
||||||
|
def set_usage_sink(sink: Optional[CompositeUsageSink]) -> None:
|
||||||
|
"""Replace the shared sink (``None`` restores the default on next use).
|
||||||
|
|
||||||
|
Used by tests and by the app shell when it wants a different fan-out; kept
|
||||||
|
explicit so nothing silently reconfigures telemetry mid-run.
|
||||||
|
"""
|
||||||
|
global _sink
|
||||||
|
with _sink_lock:
|
||||||
|
_sink = sink
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe(sink: UsageEventSink) -> UsageEventSink:
|
||||||
|
"""Attach an extra subscriber to the shared sink and return it (so callers
|
||||||
|
can keep the handle for a later :func:`unsubscribe`)."""
|
||||||
|
get_usage_sink().add(sink)
|
||||||
|
return sink
|
||||||
|
|
||||||
|
|
||||||
|
def unsubscribe(sink: UsageEventSink) -> None:
|
||||||
|
"""Detach a subscriber previously passed to :func:`subscribe`."""
|
||||||
|
get_usage_sink().remove(sink)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(event: UsageEvent) -> None:
|
||||||
|
"""Publish one usage event to every subscriber.
|
||||||
|
|
||||||
|
Never raises: called from inside a provider's streaming loop, where an
|
||||||
|
exception would abort an otherwise successful turn.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
get_usage_sink().emit(event)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("usage sink: publish failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""~4 chars per token approximation, re-exported so provider adapters need
|
||||||
|
exactly ONE telemetry import instead of also importing the tracker."""
|
||||||
|
return max(0, len(text or "") // 4)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UsageEvent",
|
||||||
|
"UsageEventSink",
|
||||||
|
"UsageTrackerSink",
|
||||||
|
"InMemoryUsageSink",
|
||||||
|
"CompositeUsageSink",
|
||||||
|
"get_usage_sink",
|
||||||
|
"set_usage_sink",
|
||||||
|
"subscribe",
|
||||||
|
"unsubscribe",
|
||||||
|
"publish",
|
||||||
|
"estimate_tokens",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation dashboard package: TokenUsageCardWidget, UsageChartWidget, HabitsWidget."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation folder package: WorkspaceFileTree, DocumentPreviewManager, AiFileEditorDialog."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation graph package: StructureGraphView and GraphQaWidget."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation monitoring package: 8 modular sub-tab widgets."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation scheduling package: KanbanBoardWidget, CalendarViewWidget, AiTaskCreatorDialog."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation settings package: Section widgets for provider, connector, routing, and general settings."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Presentation shell package: MainWindow shell, TrayManager, LifecycleCoordinator."""
|
||||||
+19
-7
@@ -292,19 +292,31 @@ class AnthropicProvider(Provider):
|
|||||||
args = {"_raw": b["json"]}
|
args = {"_raw": b["json"]}
|
||||||
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
|
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
|
||||||
|
|
||||||
# Dashboard usage event — real counts from the stream's usage events,
|
# Usage event — real counts from the stream's usage events, else a
|
||||||
# else a ~4 chars/token estimate. Never breaks the turn.
|
# ~4 chars/token estimate. Published to the telemetry sink (R03-T06)
|
||||||
|
# rather than written straight to the Dashboard store, so the provider
|
||||||
|
# stays a pure transport adapter. Never breaks the turn.
|
||||||
try:
|
try:
|
||||||
from ..core import usage_tracker as ut
|
from ..infrastructure.telemetry import usage_sink
|
||||||
|
|
||||||
if usage_seen:
|
if usage_seen:
|
||||||
ut.record(self.name, self.model, usage_seen.get("in", 0),
|
usage_sink.publish(usage_sink.UsageEvent(
|
||||||
usage_seen.get("out", 0), usage_seen.get("cache", 0))
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
input_tokens=usage_seen.get("in", 0),
|
||||||
|
output_tokens=usage_seen.get("out", 0),
|
||||||
|
cached_tokens=usage_seen.get("cache", 0),
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
||||||
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
||||||
ut.record(self.name, self.model, ut.estimate_tokens(sent),
|
usage_sink.publish(usage_sink.UsageEvent(
|
||||||
ut.estimate_tokens(got), 0, estimated=True)
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
input_tokens=usage_sink.estimate_tokens(sent),
|
||||||
|
output_tokens=usage_sink.estimate_tokens(got),
|
||||||
|
estimated=True,
|
||||||
|
))
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+24
-17
@@ -1,25 +1,32 @@
|
|||||||
"""Build a provider instance from the application config."""
|
"""Build a provider instance from the application config.
|
||||||
|
|
||||||
|
Kept as the historic entry point (``providers.build_provider``) that call sites
|
||||||
|
across the app already import, but it no longer owns a provider table of its
|
||||||
|
own: since R03-T02 the catalogue lives in
|
||||||
|
``infrastructure/providers/provider_registry.py`` so provider ids, wire
|
||||||
|
protocols, default models and capabilities are declared exactly once.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from .anthropic import AnthropicProvider
|
|
||||||
from .base import Provider, ProviderError
|
from .base import Provider, ProviderError
|
||||||
from .openai_compat import OpenAICompatProvider
|
|
||||||
|
|
||||||
_REGISTRY = {
|
|
||||||
"openai_compat": OpenAICompatProvider,
|
|
||||||
"anthropic": AnthropicProvider,
|
|
||||||
# All OpenAI-compatible endpoints (Ollama's /v1 server, the Copilot chat API,
|
|
||||||
# and OpenAI itself) speak the same Chat Completions protocol.
|
|
||||||
"ollama": OpenAICompatProvider,
|
|
||||||
"github_copilot": OpenAICompatProvider,
|
|
||||||
"codex": OpenAICompatProvider,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
|
def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
|
||||||
cls = _REGISTRY.get(name)
|
"""Construct the adapter registered for ``name``.
|
||||||
if cls is None:
|
|
||||||
raise ProviderError(f"Unsupported provider: {name}")
|
Delegates to the central registry and translates its lookup failure into
|
||||||
return cls(conf)
|
:class:`ProviderError`, because every existing call site (chat turns,
|
||||||
|
Settings' connection test, the routing prober) already handles that type —
|
||||||
|
changing the exception would ripple into unrelated error handling.
|
||||||
|
"""
|
||||||
|
from ..infrastructure.providers.provider_registry import (
|
||||||
|
ProviderNotFoundError,
|
||||||
|
default_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return default_registry().build(name, conf)
|
||||||
|
except ProviderNotFoundError as exc:
|
||||||
|
raise ProviderError(f"Unsupported provider: {name}") from exc
|
||||||
|
|||||||
+26
-10
@@ -266,22 +266,38 @@ class OpenAICompatProvider(Provider):
|
|||||||
return _assemble_assistant(text_parts, tool_acc)
|
return _assemble_assistant(text_parts, tool_acc)
|
||||||
|
|
||||||
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
|
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
|
||||||
"""One Dashboard usage event per turn: real counts when the server's
|
"""Publish one usage event per turn: real counts when the server's final
|
||||||
final chunk carried a "usage" block, a ~4 chars/token estimate
|
chunk carried a "usage" block, a ~4 chars/token estimate otherwise.
|
||||||
otherwise. Never breaks the turn."""
|
|
||||||
|
Since R03-T06 this only *describes* what the turn consumed and hands the
|
||||||
|
event to ``infrastructure/telemetry/usage_sink.py``; deciding where the
|
||||||
|
numbers land (Dashboard files, cost meters, tests) belongs to the
|
||||||
|
subscribers, not to a provider adapter. Never breaks the turn.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from ..core import usage_tracker as ut
|
from ..infrastructure.telemetry import usage_sink
|
||||||
|
|
||||||
if usage_seen:
|
if usage_seen:
|
||||||
ut.record(self.name, self.model,
|
usage_sink.publish(usage_sink.UsageEvent(
|
||||||
usage_seen.get("prompt_tokens", 0),
|
provider=self.name,
|
||||||
usage_seen.get("completion_tokens", 0),
|
model=self.model,
|
||||||
(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
|
input_tokens=usage_seen.get("prompt_tokens", 0),
|
||||||
|
output_tokens=usage_seen.get("completion_tokens", 0),
|
||||||
|
cached_tokens=(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0),
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
|
# No usage block from the gateway — approximate from the exact
|
||||||
|
# bytes we sent and received so the Dashboard still shows a
|
||||||
|
# (clearly flagged) figure instead of a silent zero.
|
||||||
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
||||||
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
||||||
ut.record(self.name, self.model, ut.estimate_tokens(sent),
|
usage_sink.publish(usage_sink.UsageEvent(
|
||||||
ut.estimate_tokens(got), 0, estimated=True)
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
input_tokens=usage_sink.estimate_tokens(sent),
|
||||||
|
output_tokens=usage_sink.estimate_tokens(got),
|
||||||
|
estimated=True,
|
||||||
|
))
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
PySide6>=6.6
|
|
||||||
pydantic>=2
|
|
||||||
requests
|
|
||||||
psutil
|
|
||||||
pygments
|
|
||||||
openpyxl
|
|
||||||
python-pptx
|
|
||||||
networkx
|
|
||||||
pytest
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
|
||||||
|
|
||||||
|
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
|
||||||
|
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
|
||||||
|
chạy lần đầu rồi mới biết còn sót.
|
||||||
|
|
||||||
|
Quét gì:
|
||||||
|
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
|
||||||
|
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
|
||||||
|
|
||||||
|
Tìm hai loại:
|
||||||
|
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
|
||||||
|
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
|
||||||
|
|
||||||
|
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
|
||||||
|
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
|
||||||
|
|
||||||
|
Chạy: python scripts/audit_security.py [--json]
|
||||||
|
Mã thoát: 0 = sạch, 1 = có phát hiện.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
|
||||||
|
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
|
||||||
|
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
|
||||||
|
|
||||||
|
# tên trường coi là nhạy cảm
|
||||||
|
SENSITIVE = re.compile(
|
||||||
|
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
|
||||||
|
r"access[_-]?key|private[_-]?key|credential)", re.I)
|
||||||
|
|
||||||
|
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
|
||||||
|
SHAPES = [
|
||||||
|
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
|
||||||
|
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
|
||||||
|
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
|
||||||
|
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
|
||||||
|
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
||||||
|
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
||||||
|
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
|
||||||
|
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
|
||||||
|
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
|
||||||
|
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
|
||||||
|
|
||||||
|
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
|
||||||
|
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
|
||||||
|
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
||||||
|
|
||||||
|
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
|
||||||
|
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
|
||||||
|
MIN_SECRET_LEN = 12
|
||||||
|
|
||||||
|
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
|
||||||
|
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
|
||||||
|
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
|
||||||
|
"always", "never", "auto", "default", "disabled", "enabled"}
|
||||||
|
|
||||||
|
# giá trị vô hại — không tính là phát hiện
|
||||||
|
PLACEHOLDER = re.compile(
|
||||||
|
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
|
||||||
|
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
|
||||||
|
r"sk-\.\.\.|\.\.\.)$", re.I)
|
||||||
|
|
||||||
|
# gán chuỗi trong Python: api_key = "..."
|
||||||
|
PY_ASSIGN = re.compile(
|
||||||
|
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
|
||||||
|
r"""["']([^"']*)["']""", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_placeholder(value: str) -> bool:
|
||||||
|
v = value.strip()
|
||||||
|
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
|
||||||
|
return True
|
||||||
|
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
|
||||||
|
return True
|
||||||
|
# quá ngắn để là credential thật
|
||||||
|
return len(v) < MIN_SECRET_LEN
|
||||||
|
|
||||||
|
|
||||||
|
def _walk():
|
||||||
|
for path in REPO.rglob("*"):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if any(part in SKIP_DIRS for part in path.parts):
|
||||||
|
continue
|
||||||
|
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def scan() -> list[dict]:
|
||||||
|
findings: list[dict] = []
|
||||||
|
for path in _walk():
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(REPO).as_posix()
|
||||||
|
|
||||||
|
for lineno, line in enumerate(text.splitlines(), 1):
|
||||||
|
if ALLOW_MARK.search(line):
|
||||||
|
continue
|
||||||
|
# 1. hình dạng credential thật
|
||||||
|
for label, pattern in SHAPES:
|
||||||
|
m = pattern.search(line)
|
||||||
|
if m:
|
||||||
|
findings.append({
|
||||||
|
"file": rel, "line": lineno, "kind": f"{label} credential",
|
||||||
|
"evidence": m.group(0)[:12] + "…",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. trường nhạy cảm có giá trị
|
||||||
|
for m in PY_ASSIGN.finditer(line):
|
||||||
|
field, value = m.group(1), m.group(2)
|
||||||
|
if not SENSITIVE.search(field) or _is_placeholder(value):
|
||||||
|
continue
|
||||||
|
findings.append({
|
||||||
|
"file": rel, "line": lineno,
|
||||||
|
"kind": f"trường '{field}' có giá trị",
|
||||||
|
"evidence": value[:6] + "…" if len(value) > 6 else value,
|
||||||
|
})
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _self_test() -> int:
|
||||||
|
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
|
||||||
|
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
bad = {
|
||||||
|
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
|
||||||
|
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
|
||||||
|
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
|
||||||
|
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
|
||||||
|
}
|
||||||
|
ok = {
|
||||||
|
"rỗng": '"api_key": ""',
|
||||||
|
"placeholder": '"api_key": "your-key-here"',
|
||||||
|
"ollama": '"api_key": "ollama"',
|
||||||
|
"test ngắn": 'api_key = "x"',
|
||||||
|
"hằng liệt kê": '"secret_in_output": "warning"',
|
||||||
|
}
|
||||||
|
global REPO
|
||||||
|
keep = REPO
|
||||||
|
passed = True
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
REPO = Path(tmp)
|
||||||
|
for label, line in {**bad, **ok}.items():
|
||||||
|
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
|
||||||
|
found = bool(scan())
|
||||||
|
want = label in bad
|
||||||
|
mark = "OK " if found == want else "SAI"
|
||||||
|
if found != want:
|
||||||
|
passed = False
|
||||||
|
verb = "bắt được" if found else "bỏ qua"
|
||||||
|
print(f" [{mark}] {label:14} -> {verb}")
|
||||||
|
REPO = keep
|
||||||
|
print()
|
||||||
|
print("Tự kiểm: " + ("script phân biệt đúng." if passed
|
||||||
|
else "*** script phân biệt SAI ***"))
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
|
||||||
|
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
|
||||||
|
ap.add_argument("--self-test", action="store_true",
|
||||||
|
help="cắm credential giả vào file tạm, kiểm script có bắt được")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.self_test:
|
||||||
|
return _self_test()
|
||||||
|
|
||||||
|
findings = scan()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(findings, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
n_files = sum(1 for _ in _walk())
|
||||||
|
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
|
||||||
|
if not findings:
|
||||||
|
print("\n0 credential lưu plaintext. PASS.")
|
||||||
|
else:
|
||||||
|
print(f"\n*** {len(findings)} phát hiện ***\n")
|
||||||
|
for f in findings:
|
||||||
|
print(f" {f['file']}:{f['line']}")
|
||||||
|
print(f" {f['kind']} — {f['evidence']}")
|
||||||
|
return 1 if findings else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""AST-based Static Analysis Guard for Clean Architecture Enforcement.
|
||||||
|
|
||||||
|
Scans designated Python packages (such as `domain/` and `application/`) to ensure
|
||||||
|
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt)
|
||||||
|
or concrete application shells.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, NamedTuple, Set
|
||||||
|
|
||||||
|
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.)
|
||||||
|
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||||
|
try:
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ImportViolation(NamedTuple):
|
||||||
|
file_path: Path
|
||||||
|
line_number: int
|
||||||
|
imported_module: str
|
||||||
|
rule_description: str
|
||||||
|
|
||||||
|
|
||||||
|
# Disallowed top-level package names in pure business/domain layers
|
||||||
|
FORBIDDEN_MODULE_PREFIXES: Set[str] = {
|
||||||
|
"PySide6",
|
||||||
|
"PySide2",
|
||||||
|
"PyQt6",
|
||||||
|
"PyQt5",
|
||||||
|
"ui",
|
||||||
|
"app",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default directories that must strictly adhere to Clean Architecture
|
||||||
|
DEFAULT_SCAN_DIRS: List[str] = [
|
||||||
|
"domain",
|
||||||
|
"application",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ArchitectureImportVisitor(ast.NodeVisitor):
|
||||||
|
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes."""
|
||||||
|
|
||||||
|
def __init__(self, file_path: Path, forbidden: Set[str]) -> None:
|
||||||
|
self.file_path = file_path
|
||||||
|
self.forbidden = forbidden
|
||||||
|
self.violations: List[ImportViolation] = []
|
||||||
|
|
||||||
|
def visit_Import(self, node: ast.Import) -> None:
|
||||||
|
# Check direct `import x, y` statements
|
||||||
|
for alias in node.names:
|
||||||
|
root_module = alias.name.split(".")[0]
|
||||||
|
if root_module in self.forbidden:
|
||||||
|
self.violations.append(
|
||||||
|
ImportViolation(
|
||||||
|
file_path=self.file_path,
|
||||||
|
line_number=node.lineno,
|
||||||
|
imported_module=alias.name,
|
||||||
|
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||||
|
# Check `from x import y` statements
|
||||||
|
if node.module:
|
||||||
|
root_module = node.module.split(".")[0]
|
||||||
|
if root_module in self.forbidden:
|
||||||
|
self.violations.append(
|
||||||
|
ImportViolation(
|
||||||
|
file_path=self.file_path,
|
||||||
|
line_number=node.lineno,
|
||||||
|
imported_module=node.module,
|
||||||
|
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||||
|
"""Parse a single Python file into AST and return all detected architecture import violations."""
|
||||||
|
try:
|
||||||
|
source_code = file_path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source_code, filename=str(file_path))
|
||||||
|
except (SyntaxError, UnicodeDecodeError) as exc:
|
||||||
|
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
visitor = ArchitectureImportVisitor(file_path, forbidden)
|
||||||
|
visitor.visit(tree)
|
||||||
|
return visitor.violations
|
||||||
|
|
||||||
|
|
||||||
|
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||||
|
"""Recursively scan all Python files in a directory."""
|
||||||
|
violations: List[ImportViolation] = []
|
||||||
|
if not dir_path.exists():
|
||||||
|
return violations
|
||||||
|
|
||||||
|
for py_file in dir_path.rglob("*.py"):
|
||||||
|
if py_file.is_file() and "__pycache__" not in py_file.parts:
|
||||||
|
violations.extend(scan_file(py_file, forbidden))
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI entry point for CI/pre-commit quality gate checks."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--paths",
|
||||||
|
nargs="*",
|
||||||
|
default=DEFAULT_SCAN_DIRS,
|
||||||
|
help="Paths or directories to scan (defaults to 'domain' and 'application')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--root",
|
||||||
|
default=".",
|
||||||
|
help="Root workspace directory",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root_dir = Path(args.root).resolve()
|
||||||
|
all_violations: List[ImportViolation] = []
|
||||||
|
|
||||||
|
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
|
||||||
|
|
||||||
|
for target in args.paths:
|
||||||
|
target_path = (root_dir / target).resolve()
|
||||||
|
if not target_path.exists():
|
||||||
|
# If the layer directory does not exist yet (during early migration), skip cleanly
|
||||||
|
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_path.is_file():
|
||||||
|
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||||
|
else:
|
||||||
|
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||||
|
|
||||||
|
if all_violations:
|
||||||
|
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:")
|
||||||
|
print("=" * 70)
|
||||||
|
for v in all_violations:
|
||||||
|
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path
|
||||||
|
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
|
||||||
|
print(f" Reason: {v.rule_description}")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"Total Violations: {len(all_violations)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -73,14 +73,18 @@ class AppContext:
|
|||||||
return load_project(pid)
|
return load_project(pid)
|
||||||
|
|
||||||
def project_routing_mode(self, surface: str) -> str:
|
def project_routing_mode(self, surface: str) -> str:
|
||||||
"""Effective Off/Auto/Manual routing mode for a chat ``surface`` in the
|
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
|
||||||
ACTIVE workspace: the workspace's own override wins; otherwise the
|
in the ACTIVE workspace: the workspace's own override wins; otherwise the
|
||||||
global default (``config.routing_mode_for``). This is what makes each
|
global default (``config.routing_mode_for``). This is what makes each
|
||||||
workspace keep its own routing mode."""
|
workspace keep its own routing mode.
|
||||||
|
|
||||||
|
The accepted set is taken from ``AppConfig.ROUTING_MODES`` rather than
|
||||||
|
repeated here, so adding a mode (as R03-T03 did with "fallback") stays a
|
||||||
|
one-line change instead of a hunt through every validation site."""
|
||||||
project = self._current_project()
|
project = self._current_project()
|
||||||
if project is not None:
|
if project is not None:
|
||||||
mode = (project.routing_modes or {}).get(surface, "")
|
mode = (project.routing_modes or {}).get(surface, "")
|
||||||
if mode in ("off", "auto", "manual"):
|
if mode in self.config.ROUTING_MODES:
|
||||||
return mode
|
return mode
|
||||||
return self.config.routing_mode_for(surface)
|
return self.config.routing_mode_for(surface)
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ class AppContext:
|
|||||||
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
||||||
workspace selected, falls back to the global setting so behaviour
|
workspace selected, falls back to the global setting so behaviour
|
||||||
outside a project stays global."""
|
outside a project stays global."""
|
||||||
mode = mode if mode in ("off", "auto", "manual") else "off"
|
mode = mode if mode in self.config.ROUTING_MODES else "off"
|
||||||
project = self._current_project()
|
project = self._current_project()
|
||||||
if project is None:
|
if project is None:
|
||||||
self.config.set_routing_mode_for(surface, mode)
|
self.config.set_routing_mode_for(surface, mode)
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Characterization tests for core/chat_agent.py (run_chat and run_cowork runtime seams).
|
||||||
|
|
||||||
|
These tests capture existing behavior as an executable baseline specification,
|
||||||
|
ensuring that future refactoring to ConversationApplicationService does not alter
|
||||||
|
core turn semantics, event emissions, or file handling.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from cowork_local.core import chat_agent
|
||||||
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_chat_characterization() -> None:
|
||||||
|
"""Capture baseline behavior of run_chat: system prompt insertion, streaming, and message persistence."""
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="Hello there!", chunks=["Hello ", "there!"])
|
||||||
|
|
||||||
|
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Hi assistant"}]
|
||||||
|
emitted_events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def emit(event: Dict[str, Any]) -> None:
|
||||||
|
emitted_events.append(event)
|
||||||
|
|
||||||
|
result = chat_agent.run_chat(
|
||||||
|
provider=provider,
|
||||||
|
messages=messages,
|
||||||
|
emit=emit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Verify system prompt was injected at position 0
|
||||||
|
assert messages[0]["role"] == "system"
|
||||||
|
assert "Cowork Local" in messages[0]["content"]
|
||||||
|
|
||||||
|
# 2. Verify returned assistant message
|
||||||
|
assert result["role"] == "assistant"
|
||||||
|
assert result["content"] == "Hello there!"
|
||||||
|
|
||||||
|
# 3. Verify assistant message was appended to messages list
|
||||||
|
assert messages[-1] == result
|
||||||
|
|
||||||
|
# 4. Verify emitted events sequence
|
||||||
|
text_deltas = [e["delta"] for e in emitted_events if e["type"] == "text"]
|
||||||
|
assert "".join(text_deltas) == "Hello there!"
|
||||||
|
assert any(e["type"] == "assistant_done" for e in emitted_events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cowork_save_file_characterization(tmp_path: Path) -> None:
|
||||||
|
"""Capture baseline behavior of run_cowork: tool execution loop and file production."""
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
provider = FakeProvider()
|
||||||
|
# Step 1: Model requests save_file tool
|
||||||
|
provider.queue_response(
|
||||||
|
content="Saving your requested report.",
|
||||||
|
tool_calls=[{
|
||||||
|
"id": "call_save_1",
|
||||||
|
"name": "save_file",
|
||||||
|
"arguments": {
|
||||||
|
"filename": "report.md",
|
||||||
|
"content": "# Executive Summary\nAll systems nominal.",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
# Step 2: Model finishes after tool result
|
||||||
|
provider.queue_response(
|
||||||
|
content="I have created report.md in your output directory.",
|
||||||
|
chunks=["I have created report.md in your output directory."],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Export report to markdown file"}]
|
||||||
|
emitted_events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def emit(event: Dict[str, Any]) -> None:
|
||||||
|
emitted_events.append(event)
|
||||||
|
|
||||||
|
final_messages = chat_agent.run_cowork(
|
||||||
|
provider=provider,
|
||||||
|
messages=messages,
|
||||||
|
output_dir=output_dir,
|
||||||
|
emit=emit,
|
||||||
|
enforce_rules=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Verify file was created in output directory with expected content
|
||||||
|
created_file = output_dir / "report.md"
|
||||||
|
assert created_file.exists()
|
||||||
|
assert created_file.read_text(encoding="utf-8") == "# Executive Summary\nAll systems nominal."
|
||||||
|
|
||||||
|
# 2. Verify message history contains user -> assistant (tool_calls) -> tool -> assistant
|
||||||
|
roles = [m["role"] for m in final_messages]
|
||||||
|
assert "system" in roles
|
||||||
|
assert "user" in roles
|
||||||
|
assert "tool" in roles
|
||||||
|
|
||||||
|
# 3. Verify tool result message content
|
||||||
|
tool_msg = next(m for m in final_messages if m["role"] == "tool")
|
||||||
|
assert tool_msg["name"] == "save_file"
|
||||||
|
assert "Saved report.md" in tool_msg["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cowork_cancellation_characterization(tmp_path: Path) -> None:
|
||||||
|
"""Capture cancellation behavior in run_cowork."""
|
||||||
|
output_dir = tmp_path / "output_cancel"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="Working...")
|
||||||
|
|
||||||
|
is_cancelled = True
|
||||||
|
|
||||||
|
def check_cancel() -> bool:
|
||||||
|
return is_cancelled
|
||||||
|
|
||||||
|
emitted_events: List[Dict[str, Any]] = []
|
||||||
|
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Please start"}]
|
||||||
|
|
||||||
|
chat_agent.run_cowork(
|
||||||
|
provider=provider,
|
||||||
|
messages=messages,
|
||||||
|
output_dir=output_dir,
|
||||||
|
emit=lambda e: emitted_events.append(e),
|
||||||
|
cancel=check_cancel,
|
||||||
|
enforce_rules=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider should not have executed turns if cancelled right away
|
||||||
|
assert provider.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_turn_output_characterization(tmp_path: Path) -> None:
|
||||||
|
"""Capture behavior of temporary .scratch folder cleanup and artifact preservation."""
|
||||||
|
output_dir = tmp_path / "output_cleanup"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
scratch_dir = output_dir / ".scratch"
|
||||||
|
scratch_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create a generator script and a deliverable inside scratch
|
||||||
|
generator_script = scratch_dir / "gen.py"
|
||||||
|
generator_script.write_text("print('generating')", encoding="utf-8")
|
||||||
|
deliverable = scratch_dir / "data.csv"
|
||||||
|
deliverable.write_text("a,b,c\n1,2,3", encoding="utf-8")
|
||||||
|
|
||||||
|
before_snapshot = chat_agent._snapshot(output_dir)
|
||||||
|
removed, moved = chat_agent._cleanup_cowork_intermediates(output_dir, before_snapshot, cancelled=False)
|
||||||
|
|
||||||
|
# .scratch directory should be removed
|
||||||
|
assert not scratch_dir.exists()
|
||||||
|
# deliverable should be moved to output root
|
||||||
|
root_csv = output_dir / "data.csv"
|
||||||
|
assert root_csv.exists()
|
||||||
|
# script should not be in output root
|
||||||
|
assert not (output_dir / "gen.py").exists()
|
||||||
|
|
||||||
+69
-4
@@ -1,10 +1,75 @@
|
|||||||
"""Make the repository package importable when pytest runs from the repo root."""
|
"""Make THIS checkout importable as the ``cowork_local`` package during tests.
|
||||||
|
|
||||||
|
Why this is not just a ``sys.path`` insert
|
||||||
|
------------------------------------------
|
||||||
|
Test modules import the app in two different styles:
|
||||||
|
|
||||||
|
* top-level (``from providers.base import ...``) — resolved by the repository
|
||||||
|
root already sitting on ``sys.path`` when pytest is launched from it;
|
||||||
|
* fully qualified (``from cowork_local.core.routing.service import ...``) —
|
||||||
|
which only resolves when a directory literally named ``cowork_local`` is
|
||||||
|
importable.
|
||||||
|
|
||||||
|
Simply appending the repository's PARENT directory to ``sys.path`` (the previous
|
||||||
|
behaviour) makes the second style resolve against *whatever* sibling folder
|
||||||
|
happens to be called ``cowork_local`` — on a developer machine that is often an
|
||||||
|
unrelated older checkout, so the whole suite silently exercises the wrong code
|
||||||
|
while still reporting green. Instead we bind the name ``cowork_local`` in
|
||||||
|
``sys.modules`` to the package rooted at THIS repository, so both import styles
|
||||||
|
always reach the working copy under test regardless of the checkout's directory
|
||||||
|
name.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPOSITORY_PARENT = Path(__file__).resolve().parents[2]
|
# .../<checkout>/tests/conftest.py -> .../<checkout>
|
||||||
if str(REPOSITORY_PARENT) not in sys.path:
|
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(REPOSITORY_PARENT))
|
PACKAGE_NAME = "cowork_local"
|
||||||
|
|
||||||
|
# The repository root must stay importable so the top-level import style
|
||||||
|
# (``providers``/``domain``/``application``/``tests``) keeps working.
|
||||||
|
if str(PACKAGE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PACKAGE_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_checkout_as_package() -> None:
|
||||||
|
"""Register this checkout in ``sys.modules`` under the canonical package name.
|
||||||
|
|
||||||
|
Executed at import time of the conftest (i.e. before any test module is
|
||||||
|
imported) so that a stale same-named directory elsewhere on ``sys.path`` can
|
||||||
|
never win the lookup. A no-op when the package is already bound to this very
|
||||||
|
directory, which keeps repeated conftest loads (pytest-xdist, sub-sessions)
|
||||||
|
idempotent.
|
||||||
|
"""
|
||||||
|
existing = sys.modules.get(PACKAGE_NAME)
|
||||||
|
if existing is not None:
|
||||||
|
# Already bound. Only rebind when it points at a DIFFERENT checkout,
|
||||||
|
# otherwise re-executing the package __init__ would duplicate module
|
||||||
|
# state that tests may already hold references to.
|
||||||
|
origin = getattr(existing, "__file__", "") or ""
|
||||||
|
if Path(origin).resolve().parent == PACKAGE_ROOT:
|
||||||
|
return
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
PACKAGE_NAME,
|
||||||
|
PACKAGE_ROOT / "__init__.py",
|
||||||
|
# Declaring the search locations is what turns the module into a real
|
||||||
|
# package, so ``cowork_local.core.routing`` and friends resolve as
|
||||||
|
# sub-modules of this directory.
|
||||||
|
submodule_search_locations=[str(PACKAGE_ROOT)],
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None: # pragma: no cover — defensive
|
||||||
|
return
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
# Insert BEFORE executing so that a circular ``import cowork_local`` from
|
||||||
|
# inside the package body resolves to the partially-initialised module
|
||||||
|
# instead of restarting the import (standard CPython import semantics).
|
||||||
|
sys.modules[PACKAGE_NAME] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
_bind_checkout_as_package()
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Contract tests: one shared specification every interchangeable adapter must satisfy.
|
||||||
|
|
||||||
|
Unlike unit tests (which pin ONE implementation's behaviour), a contract test is
|
||||||
|
parametrised over every implementation of an interface, so adding a new provider
|
||||||
|
means adding a row — not writing a new test file — and a provider that quietly
|
||||||
|
breaks the canonical shape fails here rather than in production.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Offline transport doubles + per-protocol stream scripts for the provider contract tests.
|
||||||
|
|
||||||
|
Kept in its own module so ``test_providers.py`` stays a readable list of
|
||||||
|
assertions instead of a wall of SSE fixtures, and so the LOC ceiling (400 lines
|
||||||
|
per production file, applied here too) is comfortably met by both halves.
|
||||||
|
|
||||||
|
Nothing in here touches the network: :class:`FakeStreamResponse` mimics just
|
||||||
|
enough of ``requests.Response`` for the streaming loops in
|
||||||
|
``providers/openai_compat.py`` and ``providers/anthropic.py`` — status code,
|
||||||
|
mutable ``encoding``, ``iter_lines`` and ``close``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# Canonical turn every protocol script below must produce, so the contract test
|
||||||
|
# can assert one expected result no matter which provider produced it.
|
||||||
|
EXPECTED_TEXT = "Hello world"
|
||||||
|
EXPECTED_TOOL_CALL = {"id": "call-1", "name": "read_file", "arguments": {"path": "a.txt"}}
|
||||||
|
EXPECTED_INPUT_TOKENS = 11
|
||||||
|
EXPECTED_OUTPUT_TOKENS = 7
|
||||||
|
EXPECTED_CACHED_TOKENS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStreamResponse:
|
||||||
|
"""A minimal stand-in for a streaming ``requests.Response``.
|
||||||
|
|
||||||
|
``iter_lines`` replays pre-baked SSE lines; ``closed`` records that the
|
||||||
|
provider released the connection, which the contract asserts because a
|
||||||
|
provider that leaks the response leaks a socket per turn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
lines: Optional[List[str]] = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
body: str = "",
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._lines = list(lines or ())
|
||||||
|
self.text = body
|
||||||
|
self.headers = dict(headers or {})
|
||||||
|
self._payload = payload
|
||||||
|
self.closed = False
|
||||||
|
# Providers force UTF-8 on the response before reading it; the attribute
|
||||||
|
# simply has to exist and be writable.
|
||||||
|
self.encoding = None
|
||||||
|
|
||||||
|
def iter_lines(self, decode_unicode: bool = False):
|
||||||
|
for line in self._lines:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
if self._payload is None:
|
||||||
|
raise ValueError("no JSON payload configured on this fake response")
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(payload: Dict[str, Any]) -> str:
|
||||||
|
"""One SSE ``data:`` line carrying a JSON event."""
|
||||||
|
return "data: " + json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def openai_stream_lines() -> List[str]:
|
||||||
|
"""A complete OpenAI Chat Completions stream: text, one tool call, usage.
|
||||||
|
|
||||||
|
Split across several deltas on purpose — chunk boundaries are where naive
|
||||||
|
stream parsers break, so the contract exercises them.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
_sse({"choices": [{"delta": {"content": "Hello "}}]}),
|
||||||
|
_sse({"choices": [{"delta": {"content": "world"}}]}),
|
||||||
|
_sse({"choices": [{"delta": {"tool_calls": [{
|
||||||
|
"index": 0, "id": "call-1",
|
||||||
|
"function": {"name": "read_file", "arguments": '{"path":'},
|
||||||
|
}]}}]}),
|
||||||
|
# Arguments arrive fragmented; the provider must concatenate before parsing.
|
||||||
|
_sse({"choices": [{"delta": {"tool_calls": [{
|
||||||
|
"index": 0, "function": {"arguments": '"a.txt"}'},
|
||||||
|
}]}}]}),
|
||||||
|
_sse({
|
||||||
|
"choices": [{"delta": {}}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": EXPECTED_INPUT_TOKENS,
|
||||||
|
"completion_tokens": EXPECTED_OUTPUT_TOKENS,
|
||||||
|
"prompt_tokens_details": {"cached_tokens": EXPECTED_CACHED_TOKENS},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"data: [DONE]",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def anthropic_stream_lines() -> List[str]:
|
||||||
|
"""The same canonical turn expressed as an Anthropic Messages stream."""
|
||||||
|
return [
|
||||||
|
_sse({"type": "message_start", "message": {"usage": {
|
||||||
|
"input_tokens": EXPECTED_INPUT_TOKENS,
|
||||||
|
"cache_read_input_tokens": EXPECTED_CACHED_TOKENS,
|
||||||
|
}}}),
|
||||||
|
_sse({"type": "content_block_start", "index": 0,
|
||||||
|
"content_block": {"type": "text"}}),
|
||||||
|
_sse({"type": "content_block_delta", "index": 0,
|
||||||
|
"delta": {"type": "text_delta", "text": "Hello "}}),
|
||||||
|
_sse({"type": "content_block_delta", "index": 0,
|
||||||
|
"delta": {"type": "text_delta", "text": "world"}}),
|
||||||
|
_sse({"type": "content_block_start", "index": 1, "content_block": {
|
||||||
|
"type": "tool_use", "id": "call-1", "name": "read_file"}}),
|
||||||
|
_sse({"type": "content_block_delta", "index": 1,
|
||||||
|
"delta": {"type": "input_json_delta", "partial_json": '{"path":'}}),
|
||||||
|
_sse({"type": "content_block_delta", "index": 1,
|
||||||
|
"delta": {"type": "input_json_delta", "partial_json": '"a.txt"}'}}),
|
||||||
|
_sse({"type": "message_delta",
|
||||||
|
"usage": {"output_tokens": EXPECTED_OUTPUT_TOKENS}}),
|
||||||
|
_sse({"type": "message_stop"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Per wire protocol: how to script a successful turn, and the model-list payload
|
||||||
|
# ``list_models()`` expects. Keyed by the descriptor's wire protocol value so a
|
||||||
|
# new provider that reuses an existing protocol needs no new entry here.
|
||||||
|
PROTOCOL_FIXTURES = {
|
||||||
|
"openai_compat": {
|
||||||
|
"stream_lines": openai_stream_lines,
|
||||||
|
"models_payload": {"data": [{"id": "gpt-4o-mini"}, {"id": "gpt-4o"}]},
|
||||||
|
"expected_models": ["gpt-4o-mini", "gpt-4o"],
|
||||||
|
},
|
||||||
|
"anthropic": {
|
||||||
|
"stream_lines": anthropic_stream_lines,
|
||||||
|
"models_payload": {"data": [{"id": "claude-sonnet-4-6"}]},
|
||||||
|
"expected_models": ["claude-sonnet-4-6"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedTransport:
|
||||||
|
"""Replaces ``Provider._request`` and hands back scripted responses.
|
||||||
|
|
||||||
|
Records every call so a test can assert *how* the provider talked to the
|
||||||
|
endpoint (method, url, JSON payload) without a socket ever being opened.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, responses: List[FakeStreamResponse]) -> None:
|
||||||
|
self._responses = list(responses)
|
||||||
|
self.calls: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def __call__(self, method: str, url: str, **kwargs) -> FakeStreamResponse:
|
||||||
|
self.calls.append({"method": method, "url": url, **kwargs})
|
||||||
|
if not self._responses:
|
||||||
|
raise AssertionError(f"unexpected extra request: {method} {url}")
|
||||||
|
# Pop in order: a provider that retries gets the NEXT scripted response,
|
||||||
|
# which is how the retry/error paths are driven.
|
||||||
|
return self._responses.pop(0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_payload(self) -> Dict[str, Any]:
|
||||||
|
"""The JSON body of the most recent request."""
|
||||||
|
return self.calls[-1].get("json") or {}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EXPECTED_CACHED_TOKENS",
|
||||||
|
"EXPECTED_INPUT_TOKENS",
|
||||||
|
"EXPECTED_OUTPUT_TOKENS",
|
||||||
|
"EXPECTED_TEXT",
|
||||||
|
"EXPECTED_TOOL_CALL",
|
||||||
|
"FakeStreamResponse",
|
||||||
|
"PROTOCOL_FIXTURES",
|
||||||
|
"ScriptedTransport",
|
||||||
|
"anthropic_stream_lines",
|
||||||
|
"openai_stream_lines",
|
||||||
|
]
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""R03-T01 — the contract every LLM provider adapter must satisfy.
|
||||||
|
|
||||||
|
Parametrised over EVERY provider in the central registry
|
||||||
|
(``infrastructure/providers/provider_registry.py``), so registering a new
|
||||||
|
provider automatically subjects it to the same specification and a provider that
|
||||||
|
drifts from the canonical shapes fails here.
|
||||||
|
|
||||||
|
The contract, in one list:
|
||||||
|
|
||||||
|
* construction — the registry builds a real ``Provider`` for every id;
|
||||||
|
* ``chat()`` — canonical signature, canonical assistant message, streamed text
|
||||||
|
delivered through ``on_text``, tool calls normalised to
|
||||||
|
``{"id", "name", "arguments": dict}``, response always closed;
|
||||||
|
* tool schema translation matches the adapter's wire protocol;
|
||||||
|
* failures raise ``ProviderError`` — never a bare transport exception;
|
||||||
|
* ``list_models()`` / ``test_connection()`` report a reason instead of a silent
|
||||||
|
empty list;
|
||||||
|
* telemetry — exactly one ``UsageEvent`` per turn (R03-T06), with the real
|
||||||
|
counts when the stream reports them.
|
||||||
|
|
||||||
|
Everything runs offline: ``Provider._request`` is replaced by a scripted
|
||||||
|
transport, so the suite needs no network, no API key and no Qt event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from cowork_local.infrastructure.providers.provider_registry import (
|
||||||
|
BUILTIN_DESCRIPTORS,
|
||||||
|
ProviderRegistry,
|
||||||
|
)
|
||||||
|
from cowork_local.infrastructure.telemetry import usage_sink
|
||||||
|
from cowork_local.providers.base import Provider, ProviderError, ToolSpec
|
||||||
|
from cowork_local.tests.contracts.provider_stubs import (
|
||||||
|
EXPECTED_CACHED_TOKENS,
|
||||||
|
EXPECTED_INPUT_TOKENS,
|
||||||
|
EXPECTED_OUTPUT_TOKENS,
|
||||||
|
EXPECTED_TEXT,
|
||||||
|
EXPECTED_TOOL_CALL,
|
||||||
|
PROTOCOL_FIXTURES,
|
||||||
|
FakeStreamResponse,
|
||||||
|
ScriptedTransport,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every provider id in the catalogue — the parametrisation that makes this a
|
||||||
|
# contract suite rather than a per-adapter unit test.
|
||||||
|
PROVIDER_IDS = [d.provider_id for d in BUILTIN_DESCRIPTORS]
|
||||||
|
|
||||||
|
# Minimal config: enough for any adapter to build a URL and headers offline.
|
||||||
|
BASE_CONF = {"base_url": "https://gateway.test/v1", "api_key": "test-key"}
|
||||||
|
|
||||||
|
SAMPLE_MESSAGES = [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
|
{"role": "user", "content": "Say hello"},
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_TOOL = ToolSpec(
|
||||||
|
name="read_file",
|
||||||
|
description="Read a file from disk",
|
||||||
|
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry() -> ProviderRegistry:
|
||||||
|
"""A private registry per test so registrations never leak between tests."""
|
||||||
|
return ProviderRegistry(BUILTIN_DESCRIPTORS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def collected_usage(monkeypatch) -> usage_sink.InMemoryUsageSink:
|
||||||
|
"""Swap the process-wide telemetry sink for an in-memory one.
|
||||||
|
|
||||||
|
Restored by monkeypatch after each test, so a contract run never appends to
|
||||||
|
the developer's real ``~/.cowork_local/usage/`` files.
|
||||||
|
"""
|
||||||
|
sink = usage_sink.InMemoryUsageSink()
|
||||||
|
monkeypatch.setattr(usage_sink, "_sink", usage_sink.CompositeUsageSink([sink]))
|
||||||
|
return sink
|
||||||
|
|
||||||
|
|
||||||
|
def _fixtures_for(registry: ProviderRegistry, provider_id: str) -> dict:
|
||||||
|
"""The stream/model-list script matching this provider's wire protocol."""
|
||||||
|
protocol = registry.get(provider_id).wire_protocol.value
|
||||||
|
return PROTOCOL_FIXTURES[protocol]
|
||||||
|
|
||||||
|
|
||||||
|
def _build(registry: ProviderRegistry, provider_id: str, transport=None) -> Provider:
|
||||||
|
"""Build a provider and (optionally) replace its transport with a script."""
|
||||||
|
provider = registry.build(provider_id, dict(BASE_CONF))
|
||||||
|
if transport is not None:
|
||||||
|
# Patch the INSTANCE, not the class: parallel parametrised cases must
|
||||||
|
# not see each other's scripted transport.
|
||||||
|
provider._request = transport
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Construction & interface shape
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_registry_builds_a_provider_for_every_registered_id(registry, provider_id) -> None:
|
||||||
|
"""Every catalogued provider must be constructible — a descriptor with no
|
||||||
|
working adapter is a broken entry, not a feature flag."""
|
||||||
|
provider = _build(registry, provider_id)
|
||||||
|
|
||||||
|
assert isinstance(provider, Provider)
|
||||||
|
# The registry fills in the descriptor's default model when config omits it,
|
||||||
|
# so a half-configured provider still names a concrete model.
|
||||||
|
assert provider.model, f"{provider_id} built without a model id"
|
||||||
|
assert provider.describe() == f"{provider.name}:{provider.model}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_chat_signature_is_uniform(registry, provider_id) -> None:
|
||||||
|
"""All adapters accept the same call, so the agent runtime can swap
|
||||||
|
providers without knowing which one it holds."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
provider = _build(registry, provider_id)
|
||||||
|
params = list(inspect.signature(provider.chat).parameters)
|
||||||
|
|
||||||
|
assert params == ["messages", "tools", "on_text", "cancel", "on_reasoning"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_tool_schema_matches_the_wire_protocol(registry, provider_id) -> None:
|
||||||
|
"""A ToolSpec must translate into the exact shape the endpoint expects."""
|
||||||
|
descriptor = registry.get(provider_id)
|
||||||
|
|
||||||
|
if descriptor.wire_protocol.value == "anthropic":
|
||||||
|
translated = SAMPLE_TOOL.to_anthropic()
|
||||||
|
assert translated["input_schema"] == SAMPLE_TOOL.parameters
|
||||||
|
assert translated["name"] == "read_file"
|
||||||
|
else:
|
||||||
|
translated = SAMPLE_TOOL.to_openai()
|
||||||
|
assert translated["type"] == "function"
|
||||||
|
assert translated["function"]["parameters"] == SAMPLE_TOOL.parameters
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The turn itself
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_chat_returns_the_canonical_assistant_message(registry, provider_id, collected_usage) -> None:
|
||||||
|
"""Whatever the wire format, one turn yields the same canonical result."""
|
||||||
|
fixtures = _fixtures_for(registry, provider_id)
|
||||||
|
response = FakeStreamResponse(lines=fixtures["stream_lines"]())
|
||||||
|
transport = ScriptedTransport([response])
|
||||||
|
provider = _build(registry, provider_id, transport)
|
||||||
|
|
||||||
|
streamed: list = []
|
||||||
|
result = provider.chat(
|
||||||
|
SAMPLE_MESSAGES, tools=[SAMPLE_TOOL], on_text=streamed.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["role"] == "assistant"
|
||||||
|
assert result["content"] == EXPECTED_TEXT
|
||||||
|
# Text must arrive incrementally, not only in the final message — the chat
|
||||||
|
# UI streams from these callbacks.
|
||||||
|
assert "".join(streamed) == EXPECTED_TEXT
|
||||||
|
assert len(streamed) >= 2
|
||||||
|
# Tool calls are normalised: parsed arguments, never the raw JSON fragments.
|
||||||
|
assert result["tool_calls"] == [EXPECTED_TOOL_CALL]
|
||||||
|
assert response.closed, "provider left the streaming response open"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_chat_publishes_exactly_one_usage_event(registry, provider_id, collected_usage) -> None:
|
||||||
|
"""R03-T06: a turn reports its token usage through the telemetry sink, with
|
||||||
|
the server's real counts when the stream carried them."""
|
||||||
|
fixtures = _fixtures_for(registry, provider_id)
|
||||||
|
transport = ScriptedTransport([FakeStreamResponse(lines=fixtures["stream_lines"]())])
|
||||||
|
provider = _build(registry, provider_id, transport)
|
||||||
|
|
||||||
|
provider.chat(SAMPLE_MESSAGES, tools=[SAMPLE_TOOL])
|
||||||
|
|
||||||
|
events = collected_usage.snapshot()
|
||||||
|
assert len(events) == 1, "a turn must publish exactly one usage event"
|
||||||
|
event = events[0]
|
||||||
|
assert event.provider == provider.name
|
||||||
|
assert event.model == provider.model
|
||||||
|
assert event.input_tokens == EXPECTED_INPUT_TOKENS
|
||||||
|
assert event.output_tokens == EXPECTED_OUTPUT_TOKENS
|
||||||
|
assert event.cached_tokens == EXPECTED_CACHED_TOKENS
|
||||||
|
# Real counts were available, so the event must NOT be flagged as a guess.
|
||||||
|
assert event.estimated is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_usage_is_estimated_when_the_stream_reports_none(registry, provider_id, collected_usage) -> None:
|
||||||
|
"""Gateways that never send usage still produce a dashboard row — clearly
|
||||||
|
flagged as an estimate rather than silently recorded as zero."""
|
||||||
|
# Only text; no usage block anywhere in the stream.
|
||||||
|
silent_stream = ['data: ' + '{"choices": [{"delta": {"content": "hi"}}]}', "data: [DONE]"]
|
||||||
|
if registry.get(provider_id).wire_protocol.value == "anthropic":
|
||||||
|
silent_stream = [
|
||||||
|
'data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}',
|
||||||
|
'data: {"type": "content_block_delta", "index": 0,'
|
||||||
|
' "delta": {"type": "text_delta", "text": "hi"}}',
|
||||||
|
]
|
||||||
|
transport = ScriptedTransport([FakeStreamResponse(lines=silent_stream)])
|
||||||
|
provider = _build(registry, provider_id, transport)
|
||||||
|
|
||||||
|
provider.chat(SAMPLE_MESSAGES)
|
||||||
|
|
||||||
|
events = collected_usage.snapshot()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].estimated is True
|
||||||
|
# An estimate still has to be a positive number to be worth showing.
|
||||||
|
assert events[0].total_tokens > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Failure behaviour
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_http_error_becomes_provider_error(registry, provider_id, collected_usage) -> None:
|
||||||
|
"""Callers handle exactly one exception type; adapters must not leak
|
||||||
|
transport- or JSON-level errors past their boundary."""
|
||||||
|
failing = FakeStreamResponse(status_code=401, body='{"error": {"message": "bad key"}}')
|
||||||
|
transport = ScriptedTransport([failing])
|
||||||
|
provider = _build(registry, provider_id, transport)
|
||||||
|
|
||||||
|
with pytest.raises(ProviderError):
|
||||||
|
provider.chat(SAMPLE_MESSAGES)
|
||||||
|
|
||||||
|
assert failing.closed, "provider left a failed response open"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_list_models_and_test_connection_report_a_reason(registry, provider_id) -> None:
|
||||||
|
"""A failed model load must explain itself: ``last_error`` is what Settings
|
||||||
|
shows instead of an unexplained empty dropdown."""
|
||||||
|
def _boom(*_args, **_kwargs):
|
||||||
|
# A transport failure, i.e. what actually happens when the gateway is
|
||||||
|
# unreachable — adapters translate this class of error, not arbitrary
|
||||||
|
# programming errors, which must still surface as bugs.
|
||||||
|
raise requests.ConnectionError("network down")
|
||||||
|
|
||||||
|
provider = _build(registry, provider_id, _boom)
|
||||||
|
|
||||||
|
models = provider.list_models()
|
||||||
|
|
||||||
|
assert provider.last_error, f"{provider_id} swallowed a model-load failure"
|
||||||
|
ok, message = provider.test_connection()
|
||||||
|
assert ok is False
|
||||||
|
assert message
|
||||||
|
# Anthropic answers with a built-in fallback catalogue; a gateway answers
|
||||||
|
# with nothing. Both are acceptable — the contract is only that a failure is
|
||||||
|
# never reported as success.
|
||||||
|
assert isinstance(models, list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_list_models_returns_ids_on_success(registry, provider_id) -> None:
|
||||||
|
"""The happy path returns plain model-id strings, not raw API objects."""
|
||||||
|
fixtures = _fixtures_for(registry, provider_id)
|
||||||
|
transport = ScriptedTransport([
|
||||||
|
FakeStreamResponse(status_code=200, payload=fixtures["models_payload"]),
|
||||||
|
])
|
||||||
|
provider = _build(registry, provider_id, transport)
|
||||||
|
|
||||||
|
models = provider.list_models()
|
||||||
|
|
||||||
|
assert models == fixtures["expected_models"]
|
||||||
|
assert provider.last_error == ""
|
||||||
|
assert all(isinstance(m, str) for m in models)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider_id", PROVIDER_IDS)
|
||||||
|
def test_strip_think_removes_inline_reasoning(registry, provider_id) -> None:
|
||||||
|
"""Reasoning must never leak into a final answer, whichever adapter ran."""
|
||||||
|
provider = _build(registry, provider_id)
|
||||||
|
|
||||||
|
cleaned = provider.strip_think("<think>secret plan</think>Visible answer")
|
||||||
|
|
||||||
|
assert cleaned == "Visible answer"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.
|
||||||
|
|
||||||
|
Gói này cố ý **không** import sẵn fake nào. Import ở đây là import háo hức:
|
||||||
|
chạm vào bất kỳ fake nào là kéo theo mọi phụ thuộc của nó, nên chỉ cần một
|
||||||
|
fake lỡ import module cần sys.path đặc biệt là cả gói hỏng trong môi trường
|
||||||
|
cô lập. Đã xảy ra thật khi merge Delta: `fake_provider` dùng
|
||||||
|
`from providers.base import ...` (import tuyệt đối) làm đứt bài kiểm
|
||||||
|
"dùng fake mà không nạp config thật".
|
||||||
|
|
||||||
|
Import thẳng module cần dùng:
|
||||||
|
|
||||||
|
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
|
||||||
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||||
|
"""
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ.
|
||||||
|
|
||||||
|
Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi
|
||||||
|
bản thật xong ngày 23/08 và 26/08.
|
||||||
|
|
||||||
|
Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài
|
||||||
|
mili giây.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSecretStore:
|
||||||
|
"""SecretStore trong bộ nhớ.
|
||||||
|
|
||||||
|
>>> s = FakeSecretStore({"provider:openai": "sk-test"})
|
||||||
|
>>> s.get("provider:openai")
|
||||||
|
'sk-test'
|
||||||
|
>>> s.get("provider:chua-co") is None
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, seed: Dict[str, str] | None = None):
|
||||||
|
self._items: Dict[str, str] = dict(seed or {})
|
||||||
|
|
||||||
|
def get(self, key: str) -> str | None:
|
||||||
|
return self._items.get(key)
|
||||||
|
|
||||||
|
def set(self, key: str, value: str) -> None:
|
||||||
|
self._items[key] = value
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._items.pop(key, None)
|
||||||
|
|
||||||
|
def has(self, key: str) -> bool:
|
||||||
|
return key in self._items
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfigRepository:
|
||||||
|
"""ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý.
|
||||||
|
|
||||||
|
Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống
|
||||||
|
mình cần::
|
||||||
|
|
||||||
|
cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, active_provider: str = "ollama",
|
||||||
|
providers: Dict[str, Dict[str, Any]] | None = None,
|
||||||
|
shared_dir: str = "", theme: str = "dark", language: str = "vi",
|
||||||
|
routing: Dict[str, Any] | None = None,
|
||||||
|
auth: Dict[str, Any] | None = None,
|
||||||
|
agent_security: Dict[str, Any] | None = None,
|
||||||
|
tools_disabled: list[str] | None = None,
|
||||||
|
history_dir: Path | None = None,
|
||||||
|
output_dir: Path | None = None):
|
||||||
|
self._active_provider = active_provider
|
||||||
|
self._providers = providers or {
|
||||||
|
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"},
|
||||||
|
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
|
||||||
|
}
|
||||||
|
self._shared_dir = shared_dir
|
||||||
|
self._theme = theme
|
||||||
|
self._language = language
|
||||||
|
self._routing = routing or {"mode": "off"}
|
||||||
|
self._auth = auth or {}
|
||||||
|
self._agent_security = agent_security or {"cowork_confirm_commands": True}
|
||||||
|
self._tools_disabled = list(tools_disabled or [])
|
||||||
|
self._history_dir = history_dir or Path("/fake/history")
|
||||||
|
self._output_dir = output_dir or Path("/fake/workspace")
|
||||||
|
#: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa
|
||||||
|
self.saves = 0
|
||||||
|
|
||||||
|
# ---- provider ------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def active_provider(self) -> str:
|
||||||
|
return self._active_provider
|
||||||
|
|
||||||
|
def set_active_provider(self, name: str) -> None:
|
||||||
|
self._active_provider = name
|
||||||
|
|
||||||
|
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||||
|
return dict(self._providers.get(name or self._active_provider, {}))
|
||||||
|
|
||||||
|
# ---- đường dẫn -----------------------------------------------------
|
||||||
|
@property
|
||||||
|
def shared_dir(self) -> str:
|
||||||
|
return self._shared_dir
|
||||||
|
|
||||||
|
def history_dir(self) -> Path:
|
||||||
|
return self._history_dir
|
||||||
|
|
||||||
|
def cowork_output_dir(self) -> Path:
|
||||||
|
return self._output_dir
|
||||||
|
|
||||||
|
# ---- giao diện -----------------------------------------------------
|
||||||
|
@property
|
||||||
|
def theme(self) -> str:
|
||||||
|
return self._theme
|
||||||
|
|
||||||
|
def set_theme(self, value: str) -> None:
|
||||||
|
self._theme = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language(self) -> str:
|
||||||
|
return self._language
|
||||||
|
|
||||||
|
def set_language(self, value: str) -> None:
|
||||||
|
self._language = value
|
||||||
|
|
||||||
|
# ---- nhóm cấu hình --------------------------------------------------
|
||||||
|
@property
|
||||||
|
def routing(self) -> Dict[str, Any]:
|
||||||
|
return self._routing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth(self) -> Dict[str, Any]:
|
||||||
|
return self._auth
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agent_security(self) -> Dict[str, Any]:
|
||||||
|
return self._agent_security
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tools_disabled(self) -> list[str]:
|
||||||
|
return list(self._tools_disabled)
|
||||||
|
|
||||||
|
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||||
|
if enabled:
|
||||||
|
self._tools_disabled = [t for t in self._tools_disabled if t != name]
|
||||||
|
elif name not in self._tools_disabled:
|
||||||
|
self._tools_disabled.append(name)
|
||||||
|
|
||||||
|
# ---- ghi ------------------------------------------------------------
|
||||||
|
def save(self) -> None:
|
||||||
|
self.saves += 1
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Fake LLM Provider for offline unit, contract, and characterization testing.
|
||||||
|
|
||||||
|
Provides deterministic responses, stream simulation, tool-call dispatching,
|
||||||
|
and fault injection without requiring any external network access or API keys.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from providers.base import CancelFn, Provider, ProviderError, TextCallback, ToolSpec
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProvider(Provider):
|
||||||
|
"""Deterministic test double mimicking real LLM Providers (OpenAI, Anthropic, Ollama)."""
|
||||||
|
|
||||||
|
name = "fake"
|
||||||
|
supports_vision = True
|
||||||
|
|
||||||
|
def __init__(self, conf: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
# Initialize base provider with default configuration if none provided
|
||||||
|
super().__init__(conf or {"model": "fake-model-v1"})
|
||||||
|
# History of all message batches sent across all chat calls
|
||||||
|
self.call_history: List[List[Dict[str, Any]]] = []
|
||||||
|
# Queue of programmed assistant responses to return sequentially
|
||||||
|
self.response_queue: List[Dict[str, Any]] = []
|
||||||
|
# Queue of exceptions to raise on corresponding calls
|
||||||
|
self.error_queue: List[Exception] = []
|
||||||
|
# Default text returned when response queue is empty
|
||||||
|
self.default_text: str = "Fake model response."
|
||||||
|
# Total number of chat invocations
|
||||||
|
self.call_count: int = 0
|
||||||
|
# Recorded tool specs passed into each turn
|
||||||
|
self.last_tools: Optional[List[ToolSpec]] = None
|
||||||
|
|
||||||
|
def queue_response(
|
||||||
|
self,
|
||||||
|
content: str = "",
|
||||||
|
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
reasoning: Optional[str] = None,
|
||||||
|
chunks: Optional[List[str]] = None,
|
||||||
|
) -> FakeProvider:
|
||||||
|
"""Enqueue a pre-configured response structure for upcoming chat turns."""
|
||||||
|
self.response_queue.append({
|
||||||
|
"content": content,
|
||||||
|
"tool_calls": tool_calls or [],
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"chunks": chunks or ([content] if content else []),
|
||||||
|
})
|
||||||
|
return self
|
||||||
|
|
||||||
|
def queue_error(self, exc: Exception) -> FakeProvider:
|
||||||
|
"""Enqueue an exception to simulate network/API errors on the next turn."""
|
||||||
|
self.error_queue.append(exc)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
tools: Optional[List[ToolSpec]] = None,
|
||||||
|
on_text: Optional[TextCallback] = None,
|
||||||
|
cancel: Optional[CancelFn] = None,
|
||||||
|
on_reasoning: Optional[TextCallback] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Simulate single LLM turn with full streaming and tool-call support."""
|
||||||
|
self.call_count += 1
|
||||||
|
self.call_history.append([dict(m) for m in messages])
|
||||||
|
self.last_tools = tools
|
||||||
|
|
||||||
|
# 1. Check for injected errors
|
||||||
|
if self.error_queue:
|
||||||
|
raise self.error_queue.pop(0)
|
||||||
|
|
||||||
|
# 2. Check early cancellation before processing
|
||||||
|
if cancel and cancel():
|
||||||
|
raise ProviderError("Execution aborted by user cancel signal before response generation.")
|
||||||
|
|
||||||
|
# 3. Retrieve queued response or construct default response
|
||||||
|
if self.response_queue:
|
||||||
|
resp_spec = self.response_queue.pop(0)
|
||||||
|
content = resp_spec.get("content", "")
|
||||||
|
tool_calls = resp_spec.get("tool_calls", [])
|
||||||
|
reasoning = resp_spec.get("reasoning")
|
||||||
|
chunks = resp_spec.get("chunks", [content] if content else [])
|
||||||
|
else:
|
||||||
|
content = self.default_text
|
||||||
|
tool_calls = []
|
||||||
|
reasoning = None
|
||||||
|
chunks = [content]
|
||||||
|
|
||||||
|
# 4. Stream reasoning chunks if provided
|
||||||
|
if reasoning and on_reasoning:
|
||||||
|
on_reasoning(reasoning)
|
||||||
|
|
||||||
|
# 5. Stream text chunks, checking cancellation between fragments
|
||||||
|
for chunk in chunks:
|
||||||
|
if cancel and cancel():
|
||||||
|
raise ProviderError("Execution cancelled during text chunk streaming.")
|
||||||
|
if on_text and chunk:
|
||||||
|
on_text(chunk)
|
||||||
|
|
||||||
|
# 6. Return canonical assistant message payload
|
||||||
|
assistant_msg: Dict[str, Any] = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
if tool_calls:
|
||||||
|
assistant_msg["tool_calls"] = tool_calls
|
||||||
|
|
||||||
|
return assistant_msg
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""Return available mock models for settings and validation tests."""
|
||||||
|
return ["fake-model-v1", "fake-reasoner-pro", "fake-vision-plus"]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Fake Tool Executor for isolated, offline agent tool-call verification.
|
||||||
|
|
||||||
|
Allows tests to verify tool invocation arguments, mock tool return values,
|
||||||
|
and simulate failures/delays without performing unsafe host disk or OS operations.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class FakeToolExecutor:
|
||||||
|
"""Mock execution engine for agent tool-call dispatching."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# History of all executed tool invocations: List of {"name": str, "args": dict, "result": dict}
|
||||||
|
self.call_log: List[Dict[str, Any]] = []
|
||||||
|
# Custom handlers registered per tool name
|
||||||
|
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
|
||||||
|
# Pre-programmed fixed responses keyed by tool name
|
||||||
|
self.mock_responses: Dict[str, Dict[str, Any]] = {}
|
||||||
|
# Default response when no specific handler or response is found
|
||||||
|
self.default_result: Dict[str, Any] = {"ok": True, "output": "Fake tool executed successfully."}
|
||||||
|
|
||||||
|
def register_handler(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||||
|
) -> FakeToolExecutor:
|
||||||
|
"""Register a dynamic handler function for a specific tool name."""
|
||||||
|
self.handlers[tool_name] = handler
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_mock_response(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
result: Dict[str, Any],
|
||||||
|
) -> FakeToolExecutor:
|
||||||
|
"""Set a static return payload for a specific tool name."""
|
||||||
|
self.mock_responses[tool_name] = result
|
||||||
|
return self
|
||||||
|
|
||||||
|
def execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute a tool call using registered mocks and record invocation details."""
|
||||||
|
# 1. Resolve result from handler, preset response, or default fallback
|
||||||
|
if tool_name in self.handlers:
|
||||||
|
result = self.handlers[tool_name](arguments)
|
||||||
|
elif tool_name in self.mock_responses:
|
||||||
|
result = self.mock_responses[tool_name]
|
||||||
|
else:
|
||||||
|
result = dict(self.default_result)
|
||||||
|
result["tool"] = tool_name
|
||||||
|
result["received_args"] = arguments
|
||||||
|
|
||||||
|
# 2. Record execution trace for post-test assertions
|
||||||
|
self.call_log.append({
|
||||||
|
"name": tool_name,
|
||||||
|
"args": dict(arguments),
|
||||||
|
"result": dict(result),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_calls_for(self, tool_name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Retrieve all recorded calls for a given tool name."""
|
||||||
|
return [call for call in self.call_log if call["name"] == tool_name]
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Clear recorded logs and registered mock responses."""
|
||||||
|
self.call_log.clear()
|
||||||
|
self.handlers.clear()
|
||||||
|
self.mock_responses.clear()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
|
||||||
|
|
||||||
|
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
|
||||||
|
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
|
||||||
|
lời::
|
||||||
|
|
||||||
|
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Dict
|
||||||
|
|
||||||
|
from cowork_local.domain.security.tool_policy import (
|
||||||
|
PolicyDecision, ToolCallRequest, allow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeToolPolicyGateway:
|
||||||
|
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
|
||||||
|
|
||||||
|
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
|
||||||
|
default: PolicyDecision | None = None,
|
||||||
|
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
|
||||||
|
#: {tên tool: quyết định} — tra trước default
|
||||||
|
self.rules = dict(rules or {})
|
||||||
|
self.default = default or allow()
|
||||||
|
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
|
||||||
|
self._decide = decide
|
||||||
|
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
|
||||||
|
self.seen: list[ToolCallRequest] = []
|
||||||
|
|
||||||
|
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||||
|
self.seen.append(request)
|
||||||
|
if self._decide is not None:
|
||||||
|
return self._decide(request)
|
||||||
|
return self.rules.get(request.name, self.default)
|
||||||
|
|
||||||
|
# ---- tiện cho test --------------------------------------------------
|
||||||
|
def asked_for(self, name: str) -> bool:
|
||||||
|
return any(r.name == name for r in self.seen)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def call_count(self) -> int:
|
||||||
|
return len(self.seen)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Offline test doubles for the R04 turn runtime seams.
|
||||||
|
|
||||||
|
Sits beside ``fake_provider.py``/``fake_tool_executor.py`` (R01-T02) and plays
|
||||||
|
the same role one level up: those fake a *provider*, these fake the ports
|
||||||
|
``ConversationApplicationService`` is driven through
|
||||||
|
(``application/conversations/turn_runtime.py``).
|
||||||
|
|
||||||
|
Deliberately dumb — they record what they were asked and return canned answers.
|
||||||
|
A failing test then points at the service under test rather than at a mock
|
||||||
|
framework's configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from cowork_local.domain.agents.agent_event import ToolPreview
|
||||||
|
from cowork_local.domain.agents.conversation_execution_request import (
|
||||||
|
ConversationExecutionRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSpec:
|
||||||
|
"""An advertised tool. The service only ever reads ``.name`` off a spec."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReply:
|
||||||
|
"""One programmed provider answer."""
|
||||||
|
|
||||||
|
def __init__(self, content: str = "", tool_calls=None, chunks=None, reasoning: str = ""):
|
||||||
|
self.content = content
|
||||||
|
self.tool_calls = tool_calls or []
|
||||||
|
# Default to streaming the whole content as a single chunk, which is what
|
||||||
|
# a non-streaming gateway effectively does.
|
||||||
|
self.chunks = chunks if chunks is not None else ([content] if content else [])
|
||||||
|
self.reasoning = reasoning
|
||||||
|
|
||||||
|
|
||||||
|
class FakeModelCall:
|
||||||
|
""":class:`ModelCallPort` returning programmed replies in order.
|
||||||
|
|
||||||
|
A programmed entry may be an exception instead of a reply, which is how a
|
||||||
|
test simulates the gateway dying mid-turn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, replies: List[Any]) -> None:
|
||||||
|
self.replies = list(replies)
|
||||||
|
self.calls: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
|
||||||
|
# Snapshot the messages: the service keeps mutating its own list, so
|
||||||
|
# storing it by reference would make every recorded call look identical.
|
||||||
|
self.calls.append({"messages": [dict(m) for m in messages],
|
||||||
|
"tool_names": [getattr(t, "name", "") for t in tools]})
|
||||||
|
reply = self.replies.pop(0) if self.replies else FakeReply(content="(default)")
|
||||||
|
if isinstance(reply, BaseException):
|
||||||
|
raise reply
|
||||||
|
if reply.reasoning and on_reasoning:
|
||||||
|
on_reasoning(reply.reasoning)
|
||||||
|
for chunk in reply.chunks:
|
||||||
|
if on_text and chunk:
|
||||||
|
on_text(chunk)
|
||||||
|
assistant: Dict[str, Any] = {"role": "assistant", "content": reply.content}
|
||||||
|
if reply.tool_calls:
|
||||||
|
assistant["tool_calls"] = reply.tool_calls
|
||||||
|
return assistant
|
||||||
|
|
||||||
|
|
||||||
|
class FakeToolRuntime:
|
||||||
|
""":class:`ToolRuntimePort` over an imaginary output folder."""
|
||||||
|
|
||||||
|
def __init__(self, specs=("save_file", "run_command", "update_plan"),
|
||||||
|
results: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||||
|
removed: Tuple[str, ...] = (), added: Tuple[str, ...] = ()) -> None:
|
||||||
|
self._specs = [FakeSpec(n) for n in specs]
|
||||||
|
self._results = results or {}
|
||||||
|
self._removed, self._added = removed, added
|
||||||
|
self.executed: List[Tuple[str, Dict[str, Any]]] = []
|
||||||
|
self.finalize_calls: List[Dict[str, Any]] = []
|
||||||
|
# When set, every executed tool streams this string through ``on_output``.
|
||||||
|
self.emit_output: Optional[str] = None
|
||||||
|
|
||||||
|
def specs(self, allowed_tools=None):
|
||||||
|
if allowed_tools is None:
|
||||||
|
return list(self._specs)
|
||||||
|
return [s for s in self._specs if s.name in allowed_tools]
|
||||||
|
|
||||||
|
def preview(self, name, args):
|
||||||
|
return ToolPreview(kind="info", title=name, text=str(args))
|
||||||
|
|
||||||
|
def execute(self, name, args, on_output=None, cancel=None):
|
||||||
|
self.executed.append((name, dict(args)))
|
||||||
|
if self.emit_output and on_output:
|
||||||
|
on_output(self.emit_output)
|
||||||
|
return dict(self._results.get(name, {"ok": True, "output": f"{name} ok"}))
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
return "before"
|
||||||
|
|
||||||
|
def finalize(self, before, cancelled=False):
|
||||||
|
self.finalize_calls.append({"before": before, "cancelled": cancelled})
|
||||||
|
return list(self._removed), list(self._added)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Small helpers shared by the turn tests.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def make_request(**overrides) -> ConversationExecutionRequest:
|
||||||
|
"""A minimal valid request; each test overrides only what it exercises."""
|
||||||
|
base: Dict[str, Any] = {"turn_id": "t1", "session_id": "s1", "prompt": "do it"}
|
||||||
|
base.update(overrides)
|
||||||
|
return ConversationExecutionRequest(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def run_turn(service, request=None, cancel=None):
|
||||||
|
"""Execute a turn and return ``(result, events)``."""
|
||||||
|
events: List[Any] = []
|
||||||
|
result = service.execute(request or make_request(), events.append, cancel=cancel)
|
||||||
|
return result, events
|
||||||
|
|
||||||
|
|
||||||
|
def events_of_type(events, cls):
|
||||||
|
"""Every emitted event of one type, in order."""
|
||||||
|
return [e for e in events if isinstance(e, cls)]
|
||||||
|
|
||||||
|
|
||||||
|
def tool_turn(tool_name: str = "save_file", args=None, **tool_kwargs):
|
||||||
|
"""A turn that calls one tool and then answers — ``(model, tools)``."""
|
||||||
|
calls = [{"id": "c1", "name": tool_name, "arguments": args or {"filename": "a.md"}}]
|
||||||
|
model = FakeModelCall([FakeReply(content="working", tool_calls=calls),
|
||||||
|
FakeReply(content="done")])
|
||||||
|
return model, FakeToolRuntime(**tool_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FakeSpec", "FakeReply", "FakeModelCall", "FakeToolRuntime",
|
||||||
|
"make_request", "run_turn", "events_of_type", "tool_turn",
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Integration tests: several real layers wired together, still fully offline.
|
||||||
|
|
||||||
|
Where unit tests pin one class against fakes and contract tests pin an interface
|
||||||
|
across implementations, these exercise a real path end to end — e.g. the
|
||||||
|
application routing service on top of the real ``core/routing`` engine — so a
|
||||||
|
seam that only works against a mock is caught here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""R04-T02 — the typed event vocabulary vs. what the real runtime emits.
|
||||||
|
|
||||||
|
The unit tests pin each event against the shape I *read* out of
|
||||||
|
``core/chat_agent.py``. This one removes the reading: it runs the actual
|
||||||
|
``run_cowork`` loop offline (FakeProvider, real tool execution, real cleanup)
|
||||||
|
and asserts every dict it emits is recognised by :func:`from_legacy_dict` and
|
||||||
|
survives a round trip byte-for-byte.
|
||||||
|
|
||||||
|
That makes it a guard against the two failure modes a hand-written vocabulary
|
||||||
|
has: an event type nobody modelled, and a key that silently changes meaning.
|
||||||
|
Either one would surface here as a failure instead of as a blank chat bubble
|
||||||
|
after R04-T03 starts routing events through the typed layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cowork_local.core import chat_agent
|
||||||
|
from cowork_local.domain.agents.agent_event_codec import from_legacy_dict
|
||||||
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _run_turn_and_collect(tmp_path: Path, provider: FakeProvider) -> List[Dict[str, Any]]:
|
||||||
|
"""Run one real ``run_cowork`` turn offline and return every emitted dict."""
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
emitted: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
chat_agent.run_cowork(
|
||||||
|
provider=provider,
|
||||||
|
messages=[{"role": "user", "content": "make me a report"}],
|
||||||
|
output_dir=output_dir,
|
||||||
|
emit=emitted.append,
|
||||||
|
# security_config=None disables the AI guardrail layers, which is the
|
||||||
|
# documented behaviour for headless callers and keeps this test offline.
|
||||||
|
security_config=None,
|
||||||
|
title="Report",
|
||||||
|
)
|
||||||
|
return emitted
|
||||||
|
|
||||||
|
|
||||||
|
def _reporting_turn(tmp_path: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""A turn that streams text, calls save_file, then answers — the common path."""
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(
|
||||||
|
content="Writing it now.",
|
||||||
|
chunks=["Writing ", "it now."],
|
||||||
|
tool_calls=[{"id": "call_1", "name": "save_file",
|
||||||
|
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Saved to report.md.", chunks=["Saved to report.md."])
|
||||||
|
return _run_turn_and_collect(tmp_path, provider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_runtime_emits_only_event_types_the_domain_layer_models(tmp_path: Path) -> None:
|
||||||
|
emitted = _reporting_turn(tmp_path)
|
||||||
|
|
||||||
|
unmodelled = sorted({e["type"] for e in emitted if from_legacy_dict(e) is None})
|
||||||
|
|
||||||
|
assert unmodelled == [], f"run_cowork emits event types R04-T02 does not model: {unmodelled}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_emitted_event_round_trips_without_losing_a_key(tmp_path: Path) -> None:
|
||||||
|
emitted = _reporting_turn(tmp_path)
|
||||||
|
assert emitted, "the turn produced no events at all — the fixture is wrong"
|
||||||
|
|
||||||
|
for raw in emitted:
|
||||||
|
event = from_legacy_dict(raw)
|
||||||
|
assert event is not None, raw
|
||||||
|
assert event.to_legacy_dict() == raw, f"round trip changed the {raw['type']} event"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tool_using_turn_really_exercises_the_tool_events(tmp_path: Path) -> None:
|
||||||
|
# Guards the test above from passing trivially: if the fixture ever stopped
|
||||||
|
# calling a tool, the round-trip check would only cover text events.
|
||||||
|
types = {e["type"] for e in _reporting_turn(tmp_path)}
|
||||||
|
|
||||||
|
assert {"text", "assistant_done", "tool_proposed", "tool_result"} <= types
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_events_from_a_thinking_model_round_trip(tmp_path: Path) -> None:
|
||||||
|
# A separate fixture because only reasoning models emit these, and the
|
||||||
|
# common-path turn above would otherwise never cover the event.
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="42", chunks=["42"], reasoning="Let me think...")
|
||||||
|
|
||||||
|
emitted = _run_turn_and_collect(tmp_path, provider)
|
||||||
|
|
||||||
|
reasoning_events = [e for e in emitted if e["type"] == "reasoning"]
|
||||||
|
assert reasoning_events, "a reasoning model produced no reasoning event"
|
||||||
|
for raw in reasoning_events:
|
||||||
|
assert from_legacy_dict(raw).to_legacy_dict() == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_events_from_the_real_update_plan_tool_round_trip(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(
|
||||||
|
content="Planning.",
|
||||||
|
tool_calls=[{"id": "call_1", "name": "update_plan",
|
||||||
|
"arguments": {"steps": [{"title": "Draft", "status": "running"},
|
||||||
|
{"title": "Review", "status": "pending"}]}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Done.")
|
||||||
|
|
||||||
|
emitted = _run_turn_and_collect(tmp_path, provider)
|
||||||
|
|
||||||
|
plan_events = [e for e in emitted if e["type"] == "plan_set"]
|
||||||
|
assert plan_events, "update_plan did not produce a plan_set event"
|
||||||
|
for raw in plan_events:
|
||||||
|
assert from_legacy_dict(raw).to_legacy_dict() == raw
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""R04-T03 (c) — the service must behave exactly like ``run_cowork``.
|
||||||
|
|
||||||
|
The unit tests prove the loop follows the rules I wrote down. They cannot prove
|
||||||
|
those rules are the ones the shipped runtime actually follows. This file does:
|
||||||
|
each test scripts one provider, runs the SAME turn twice — once through
|
||||||
|
``core/chat_agent.py::run_cowork``, once through
|
||||||
|
``ConversationApplicationService`` wired by ``core_runtime_adapter`` — and
|
||||||
|
compares the emitted event stream, the resulting conversation and the tool list
|
||||||
|
the model was shown.
|
||||||
|
|
||||||
|
Anything the port got wrong (a missing event, a reordered guard, a different
|
||||||
|
tool set, a changed message) fails here rather than in front of a user. The only
|
||||||
|
allowed difference is the extra ``turn_completed`` event R04 introduces, which
|
||||||
|
has no legacy consumer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from cowork_local.application.conversations.core_runtime_adapter import (
|
||||||
|
build_cowork_conversation_service,
|
||||||
|
legacy_event_sink,
|
||||||
|
)
|
||||||
|
from cowork_local.core import chat_agent
|
||||||
|
from cowork_local.domain.agents.conversation_execution_request import (
|
||||||
|
ConversationExecutionRequest,
|
||||||
|
)
|
||||||
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||||
|
|
||||||
|
_USER_TURN = [{"role": "user", "content": "make me a report"}]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeGate:
|
||||||
|
"""Stands in for ``core/permissions.py::PermissionGate``."""
|
||||||
|
|
||||||
|
def __init__(self, approve: bool) -> None:
|
||||||
|
self.approve = approve
|
||||||
|
self.requests: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def request(self, action: Dict[str, Any]) -> bool:
|
||||||
|
self.requests.append(action)
|
||||||
|
return self.approve
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise(events: List[Dict[str, Any]], out_dir: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""Replace the run's own output path with a placeholder.
|
||||||
|
|
||||||
|
The two runs write into different temp folders, so absolute paths in
|
||||||
|
``tool_result``/``outputs_*`` events differ by construction. Everything else
|
||||||
|
must match verbatim.
|
||||||
|
"""
|
||||||
|
marker, raw = "<OUT>", str(out_dir)
|
||||||
|
|
||||||
|
def scrub(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.replace(raw, marker).replace(raw.replace("\\", "/"), marker)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [scrub(v) for v in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: scrub(v) for k, v in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
return [scrub(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_legacy(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
|
||||||
|
gate: Optional[_FakeGate] = None, max_steps: int = 30
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
|
||||||
|
"""Run the turn through the existing ``run_cowork``."""
|
||||||
|
out_dir = tmp_path / "legacy"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
messages = [dict(m) for m in _USER_TURN]
|
||||||
|
|
||||||
|
chat_agent.run_cowork(
|
||||||
|
provider, messages, out_dir, events.append, title="Report",
|
||||||
|
security_config=None, allowed_tools=allowed_tools, gate=gate, max_steps=max_steps,
|
||||||
|
)
|
||||||
|
tool_names = [t.name for t in (provider.last_tools or [])]
|
||||||
|
return _normalise(events, out_dir), messages, tool_names
|
||||||
|
|
||||||
|
|
||||||
|
def _run_service(tmp_path: Path, provider: FakeProvider, *, allowed_tools=None,
|
||||||
|
gate: Optional[_FakeGate] = None, max_steps: int = 30
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
|
||||||
|
"""Run the same turn through the application service."""
|
||||||
|
out_dir = tmp_path / "service"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
service = build_cowork_conversation_service(
|
||||||
|
provider, out_dir, events.append, title="Report", security_config=None, gate=gate)
|
||||||
|
request = ConversationExecutionRequest(
|
||||||
|
turn_id="t1", session_id="s1",
|
||||||
|
# run_cowork receives the user message already appended; the request
|
||||||
|
# carries the history and this turn's prompt separately.
|
||||||
|
messages=_USER_TURN[:-1], prompt=_USER_TURN[-1]["content"],
|
||||||
|
output_dir=out_dir, allowed_tools=allowed_tools, max_steps=max_steps,
|
||||||
|
gate_mode="confirm" if gate is not None else "auto",
|
||||||
|
)
|
||||||
|
result = service.execute(request, legacy_event_sink(events.append))
|
||||||
|
|
||||||
|
# The end-of-turn event is new in R04 and has no legacy counterpart.
|
||||||
|
kept = [e for e in events if e.get("type") != "turn_completed"]
|
||||||
|
tool_names = [t.name for t in (provider.last_tools or [])]
|
||||||
|
return _normalise(kept, out_dir), list(result.messages), tool_names
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_parity(tmp_path: Path, script, *, approve: Optional[bool] = None, **kwargs) -> None:
|
||||||
|
"""Script two identical providers, run both paths, compare everything."""
|
||||||
|
legacy_provider, service_provider = FakeProvider(), FakeProvider()
|
||||||
|
script(legacy_provider)
|
||||||
|
script(service_provider)
|
||||||
|
|
||||||
|
legacy_gate = _FakeGate(approve) if approve is not None else None
|
||||||
|
service_gate = _FakeGate(approve) if approve is not None else None
|
||||||
|
|
||||||
|
legacy_events, legacy_messages, legacy_tools = _run_legacy(
|
||||||
|
tmp_path, legacy_provider, gate=legacy_gate, **kwargs)
|
||||||
|
service_events, service_messages, service_tools = _run_service(
|
||||||
|
tmp_path, service_provider, gate=service_gate, **kwargs)
|
||||||
|
|
||||||
|
assert service_events == legacy_events
|
||||||
|
assert service_messages == legacy_messages
|
||||||
|
assert service_tools == legacy_tools
|
||||||
|
if legacy_gate is not None and service_gate is not None:
|
||||||
|
assert [r["name"] for r in service_gate.requests] == \
|
||||||
|
[r["name"] for r in legacy_gate.requests]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Scenarios.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_a_plain_answer_turn_behaves_identically(tmp_path: Path) -> None:
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_save_file_turn_behaves_identically(tmp_path: Path) -> None:
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
provider.queue_response(
|
||||||
|
content="Writing it.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "save_file",
|
||||||
|
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Saved.")
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_update_plan_turn_behaves_identically(tmp_path: Path) -> None:
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
provider.queue_response(
|
||||||
|
content="Planning.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "update_plan",
|
||||||
|
"arguments": {"steps": [{"title": "Draft", "status": "running"},
|
||||||
|
{"title": "Ship", "status": "pending"}]}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Done.")
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reasoning_only_reply_behaves_identically(tmp_path: Path) -> None:
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
provider.queue_response(content="", reasoning="thinking hard")
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restricting_the_tool_scope_advertises_the_same_tools(tmp_path: Path) -> None:
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
provider.queue_response(content="ok")
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script, allowed_tools=["save_file"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejected_command_behaves_identically(tmp_path: Path) -> None:
|
||||||
|
# The security-critical path: the gate says no, so the command must never
|
||||||
|
# run and the model must read back the same refusal in both designs.
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
provider.queue_response(
|
||||||
|
content="Running it.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "run_command",
|
||||||
|
"arguments": {"command": "echo hi"}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Understood.")
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script, approve=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hitting_the_step_ceiling_behaves_identically(tmp_path: Path) -> None:
|
||||||
|
# The model never stops calling tools, so both paths must stop at the same
|
||||||
|
# place and say so the same way.
|
||||||
|
def script(provider: FakeProvider) -> None:
|
||||||
|
for i in range(4):
|
||||||
|
provider.queue_response(
|
||||||
|
content=f"step {i}",
|
||||||
|
tool_calls=[{"id": f"c{i}", "name": "save_file",
|
||||||
|
"arguments": {"filename": f"f{i}.md", "content": "x"}}],
|
||||||
|
)
|
||||||
|
|
||||||
|
_assert_parity(tmp_path, script, max_steps=2)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""R04-T04 — the migrated Cowork call site, exercised end to end without Qt.
|
||||||
|
|
||||||
|
``CoworkTab.build_job`` only ever *reads attributes* off its widget, so the real
|
||||||
|
production method can be invoked against a stand-in that supplies those
|
||||||
|
attributes. That is what happens here: the actual ``build_job`` body runs, builds
|
||||||
|
a request, wires the service through ``core_runtime_adapter``, and drives a real
|
||||||
|
turn (real tool execution, real output-folder cleanup) against ``FakeProvider``.
|
||||||
|
|
||||||
|
Why it matters: this is the only automated check that the widget's contract with
|
||||||
|
the service still holds — that the worker's list is appended to in place (the
|
||||||
|
transcript re-render and history merge both read it), that events still arrive as
|
||||||
|
legacy dicts, and that a produced file really lands in the turn's folder. None of
|
||||||
|
it needs a display server, so it runs in CI like every other test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
||||||
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWorker:
|
||||||
|
"""The parts of ``core/worker.py::AgentWorker`` a job actually touches."""
|
||||||
|
|
||||||
|
def __init__(self, approve_commands: bool = True) -> None:
|
||||||
|
self.events: List[Dict[str, Any]] = []
|
||||||
|
self.gate: Optional[Any] = None
|
||||||
|
self._approve = approve_commands
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
def emit_event(self, event: Dict[str, Any]) -> None:
|
||||||
|
self.events.append(event)
|
||||||
|
|
||||||
|
def is_cancelled(self) -> bool:
|
||||||
|
return self.cancelled
|
||||||
|
|
||||||
|
def new_gate(self, mode: str, agent_role: str = "") -> Any:
|
||||||
|
# Mirrors AgentWorker.new_gate: the gate is stored on the worker so the
|
||||||
|
# UI thread can resolve it, and answers request() from the worker thread.
|
||||||
|
worker = self
|
||||||
|
|
||||||
|
class _Gate:
|
||||||
|
requests: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def request(self, action: Dict[str, Any]) -> bool:
|
||||||
|
self.requests.append(action)
|
||||||
|
return worker._approve
|
||||||
|
|
||||||
|
self.gate = _Gate()
|
||||||
|
return self.gate
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCtx:
|
||||||
|
"""The ``AppContext`` surface ``build_job`` uses."""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, confirm_commands: bool = False) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._confirm = confirm_commands
|
||||||
|
|
||||||
|
def project_confirm_commands(self) -> bool:
|
||||||
|
return self._confirm
|
||||||
|
|
||||||
|
def build_mcp_tools(self):
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
|
||||||
|
class _WidgetStub:
|
||||||
|
"""Stands in for the CoworkTab instance ``build_job`` reads its state from."""
|
||||||
|
|
||||||
|
kind = "cowork"
|
||||||
|
|
||||||
|
def __init__(self, out_root: Path, ctx: _FakeCtx, provider: FakeProvider) -> None:
|
||||||
|
self._out_root = out_root
|
||||||
|
self.ctx = ctx
|
||||||
|
self._provider = provider
|
||||||
|
self.title = "Report"
|
||||||
|
self.session_id = "s1"
|
||||||
|
self.project_id = "" # the auto-seeded default workspace
|
||||||
|
self._model = ""
|
||||||
|
self._routed_provider = None
|
||||||
|
self._routed_model = None
|
||||||
|
|
||||||
|
def _session_output_dir(self) -> Path:
|
||||||
|
return self._out_root
|
||||||
|
|
||||||
|
def workspace_dir(self) -> Path:
|
||||||
|
return self._out_root
|
||||||
|
|
||||||
|
def admin_agent_prompt(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def build_provider(self) -> FakeProvider:
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> AppConfig:
|
||||||
|
"""A real AppConfig that never touches ``~/.cowork_local``.
|
||||||
|
|
||||||
|
The AI security guardrails are switched off: they would call the model to
|
||||||
|
review the prompt, which is a separate feature with its own tests and would
|
||||||
|
make this one depend on what the fake answers.
|
||||||
|
"""
|
||||||
|
data = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
data["agent_security"]["enabled"] = False
|
||||||
|
return AppConfig(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_turn(tmp_path: Path, provider: FakeProvider, messages: List[Dict[str, Any]],
|
||||||
|
*, confirm_commands: bool = False, approve: bool = True):
|
||||||
|
"""Invoke the real ``CoworkTab.build_job`` against the stub and run its job."""
|
||||||
|
from cowork_local.ui.cowork_tab import CoworkTab
|
||||||
|
|
||||||
|
out_dir = tmp_path / ".turns" / "t1"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
widget = _WidgetStub(tmp_path, _FakeCtx(_config(), confirm_commands), provider)
|
||||||
|
worker = _FakeWorker(approve_commands=approve)
|
||||||
|
|
||||||
|
job = CoworkTab.build_job(widget, "make me a report", messages, out_dir)
|
||||||
|
result = job(worker)
|
||||||
|
return result, worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_turn_runs_and_reports_its_folder(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="Here you go.", chunks=["Here ", "you go."])
|
||||||
|
messages = [{"role": "user", "content": "make me a report"}]
|
||||||
|
|
||||||
|
result, worker = _run_turn(tmp_path, provider, messages)
|
||||||
|
|
||||||
|
assert result["turn_dir"] == str(tmp_path / ".turns" / "t1")
|
||||||
|
assert [e["type"] for e in worker.events] == [
|
||||||
|
"text", "text", "assistant_done", "turn_completed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_worker_list_is_appended_to_in_place(tmp_path: Path) -> None:
|
||||||
|
# _reattach_running_turn replays from this very list while the turn runs, and
|
||||||
|
# _finalize_turn slices it by the pre-turn length afterwards.
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="Done.")
|
||||||
|
user = {"role": "user", "content": "make me a report"}
|
||||||
|
messages = [user]
|
||||||
|
|
||||||
|
result, _ = _run_turn(tmp_path, provider, messages)
|
||||||
|
|
||||||
|
assert result["messages"] is messages
|
||||||
|
# Identity, not just equality: _reattach_running_turn locates the turn's user
|
||||||
|
# message with ``m is ctx["user_msg"]`` to replay the steps after it.
|
||||||
|
assert any(m is user for m in messages)
|
||||||
|
# Several system blocks are expected — the tool prompt plus the tagged
|
||||||
|
# skills/security-rules blocks the runtime refreshes on every turn.
|
||||||
|
assert [m["role"] for m in messages if m["role"] != "system"] == ["user", "assistant"]
|
||||||
|
assert messages[-1]["content"] == "Done."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_saved_file_lands_in_the_turn_folder(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(
|
||||||
|
content="Writing it.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "save_file",
|
||||||
|
"arguments": {"filename": "report.md", "content": "# Report\n"}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Saved.")
|
||||||
|
messages = [{"role": "user", "content": "make me a report"}]
|
||||||
|
|
||||||
|
_, worker = _run_turn(tmp_path, provider, messages)
|
||||||
|
|
||||||
|
produced = list((tmp_path / ".turns" / "t1").glob("*.md"))
|
||||||
|
assert len(produced) == 1
|
||||||
|
assert produced[0].read_text(encoding="utf-8") == "# Report\n"
|
||||||
|
results = [e for e in worker.events if e["type"] == "tool_result"]
|
||||||
|
assert results and results[0]["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_run_mode_never_creates_a_permission_gate(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="ok")
|
||||||
|
|
||||||
|
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "hi"}],
|
||||||
|
confirm_commands=False)
|
||||||
|
|
||||||
|
assert worker.gate is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_mode_creates_the_gate_and_a_refusal_stops_the_command(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(
|
||||||
|
content="Running it.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "run_command",
|
||||||
|
"arguments": {"command": "echo hi"}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Understood.")
|
||||||
|
|
||||||
|
_, worker = _run_turn(tmp_path, provider, [{"role": "user", "content": "run it"}],
|
||||||
|
confirm_commands=True, approve=False)
|
||||||
|
|
||||||
|
assert worker.gate is not None
|
||||||
|
refusals = [e for e in worker.events
|
||||||
|
if e["type"] == "tool_result" and e["output"] == "Rejected by user."]
|
||||||
|
assert len(refusals) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelling_before_the_turn_starts_calls_no_model(tmp_path: Path) -> None:
|
||||||
|
from cowork_local.ui.cowork_tab import CoworkTab
|
||||||
|
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="never")
|
||||||
|
out_dir = tmp_path / ".turns" / "t1"
|
||||||
|
out_dir.mkdir(parents=True)
|
||||||
|
widget = _WidgetStub(tmp_path, _FakeCtx(_config()), provider)
|
||||||
|
worker = _FakeWorker()
|
||||||
|
worker.cancelled = True
|
||||||
|
|
||||||
|
CoworkTab.build_job(widget, "x", [{"role": "user", "content": "x"}], out_dir)(worker)
|
||||||
|
|
||||||
|
assert provider.call_count == 0
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""R03-T03/T04/T05 — the unified routing path over the REAL routing engine.
|
||||||
|
|
||||||
|
The unit tests drive ``RoutingApplicationService`` against fakes; this suite
|
||||||
|
proves the same service produces correct outcomes on top of the actual
|
||||||
|
``core/routing`` stack (classifier → assessment store → scorer → selector →
|
||||||
|
switch controller), which is what the three chat surfaces now call.
|
||||||
|
|
||||||
|
Offline by construction: a fake probe client answers benchmarks and judging, and
|
||||||
|
the assessment store is a temp file — no network, no Qt, no ``$HOME`` writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cowork_local.application.model_routing import (
|
||||||
|
AppContextModeResolver,
|
||||||
|
CoreRoutingEngine,
|
||||||
|
RoutingApplicationService,
|
||||||
|
RoutingMode,
|
||||||
|
RoutingRequest,
|
||||||
|
)
|
||||||
|
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
||||||
|
from cowork_local.core import projects as projects_mod
|
||||||
|
from cowork_local.core.routing.clients import CompletionResult
|
||||||
|
from cowork_local.core.routing.service import RoutingService
|
||||||
|
from cowork_local.core.routing.store import AssessmentStore
|
||||||
|
from cowork_local.state import AppContext
|
||||||
|
|
||||||
|
STRONG_ANSWER = "STRONG-DETAILED-CORRECT-ANSWER"
|
||||||
|
WEAK_ANSWER = "weak"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProbeClient:
|
||||||
|
"""Deterministic stand-in for the provider layer used during assessment.
|
||||||
|
|
||||||
|
Mirrors ``tests/routing/test_service.py``'s client: benchmark prompts get a
|
||||||
|
per-model canned answer, and judge prompts are graded by looking up that
|
||||||
|
answer, so scores are stable and no model is ever really called.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, answers, quality) -> None:
|
||||||
|
self.answers = answers
|
||||||
|
self.quality = quality
|
||||||
|
|
||||||
|
def complete(self, provider, model_id, messages) -> CompletionResult:
|
||||||
|
text = messages[0]["content"]
|
||||||
|
if "grading an AI assistant" in text: # the judge rubric prompt
|
||||||
|
score = 0.0
|
||||||
|
for answer, value in self.quality.items():
|
||||||
|
if answer and answer in text:
|
||||||
|
score = value
|
||||||
|
break
|
||||||
|
return CompletionResult(text='{"score": %s}' % score)
|
||||||
|
answer = self.answers.get((provider, model_id))
|
||||||
|
if answer is None:
|
||||||
|
return CompletionResult(error="unavailable")
|
||||||
|
return CompletionResult(text=answer, tokens_out=len(answer) // 4)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def ctx(tmp_path, monkeypatch):
|
||||||
|
"""An AppContext with two assessable models and temp-only persistence."""
|
||||||
|
# Keep workspace load/save off the developer's real ~/.cowork_local.
|
||||||
|
monkeypatch.setattr(projects_mod, "PROJECTS_DIR", tmp_path / "projects")
|
||||||
|
data = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
data["providers"] = {
|
||||||
|
"anthropic": {"base_url": "x", "api_key": "x", "model": "strong-model"},
|
||||||
|
}
|
||||||
|
data["routing"]["candidates"] = [
|
||||||
|
{"provider": "anthropic", "model_id": "strong-model", "tier": "powerful"},
|
||||||
|
{"provider": "anthropic", "model_id": "weak-model", "tier": "fast"},
|
||||||
|
]
|
||||||
|
data["routing"]["judge_provider"] = "anthropic"
|
||||||
|
data["routing"]["judge_model"] = "judge-model"
|
||||||
|
data["routing"]["policy"] = "quality"
|
||||||
|
data["routing"]["min_score_gain"] = 0.05
|
||||||
|
return AppContext(AppConfig(data=data, path=tmp_path / "config.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def routing_service(ctx, tmp_path) -> RoutingService:
|
||||||
|
"""A real RoutingService with a populated assessment store."""
|
||||||
|
client = FakeProbeClient(
|
||||||
|
answers={
|
||||||
|
("anthropic", "strong-model"): STRONG_ANSWER,
|
||||||
|
("anthropic", "weak-model"): WEAK_ANSWER,
|
||||||
|
},
|
||||||
|
quality={STRONG_ANSWER: 0.95, WEAK_ANSWER: 0.35},
|
||||||
|
)
|
||||||
|
store = AssessmentStore(store_path=tmp_path / "assess.json",
|
||||||
|
history_dir=tmp_path / "history")
|
||||||
|
service = RoutingService(ctx, store=store, client=client)
|
||||||
|
service.reassess() # populate real probe results + fit scores
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app_service(ctx, routing_service) -> RoutingApplicationService:
|
||||||
|
"""The application service wired exactly the way the UI wires it."""
|
||||||
|
return RoutingApplicationService(
|
||||||
|
CoreRoutingEngine(routing_service),
|
||||||
|
AppContextModeResolver(ctx),
|
||||||
|
confirm_timeout_sec=lambda: float(ctx.config.routing["confirm_timeout_sec"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def coding_request(**overrides) -> RoutingRequest:
|
||||||
|
"""A coding turn currently pinned to the weaker model."""
|
||||||
|
fields = dict(
|
||||||
|
surface="cowork",
|
||||||
|
prompt="Write a Python function to reverse a linked list",
|
||||||
|
current_provider="anthropic",
|
||||||
|
current_model="weak-model",
|
||||||
|
)
|
||||||
|
fields.update(overrides)
|
||||||
|
return RoutingRequest(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Auto / Off / Manual over the real engine
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_auto_switches_to_the_better_assessed_model(app_service) -> None:
|
||||||
|
"""The real scorer must rank the strong model first and the service must
|
||||||
|
hand that model back as this turn's override."""
|
||||||
|
outcome = app_service.resolve(coding_request(mode=RoutingMode.AUTO))
|
||||||
|
|
||||||
|
assert outcome.switched is True
|
||||||
|
assert outcome.provider == "anthropic"
|
||||||
|
assert outcome.model == "strong-model"
|
||||||
|
assert outcome.task_type == "coding" # classified from the prompt
|
||||||
|
assert outcome.score_gain > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_keeps_the_pinned_model(app_service) -> None:
|
||||||
|
"""Off must not switch even when a clearly better model is assessed."""
|
||||||
|
outcome = app_service.resolve(coding_request(mode=RoutingMode.OFF))
|
||||||
|
|
||||||
|
assert outcome.switched is False
|
||||||
|
assert outcome.provider is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_asks_before_switching(app_service) -> None:
|
||||||
|
"""The confirm callback receives the engine's own decision object, which is
|
||||||
|
what ``ui/routing_toggle.py::confirm_switch`` renders."""
|
||||||
|
seen: list = []
|
||||||
|
|
||||||
|
outcome = app_service.resolve(
|
||||||
|
coding_request(mode=RoutingMode.MANUAL),
|
||||||
|
confirm=lambda decision, timeout: seen.append((decision, timeout)) or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.switched is True
|
||||||
|
decision, timeout = seen[0]
|
||||||
|
assert decision.to_model == "anthropic/strong-model"
|
||||||
|
assert decision.reason # human-readable explanation
|
||||||
|
assert timeout == pytest.approx(60.0) # from DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_decline_keeps_the_pinned_model(app_service) -> None:
|
||||||
|
outcome = app_service.resolve(
|
||||||
|
coding_request(mode=RoutingMode.MANUAL),
|
||||||
|
confirm=lambda decision, timeout: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.switched is False
|
||||||
|
assert outcome.declined is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_best_model_is_left_alone(app_service) -> None:
|
||||||
|
"""No pointless churn: being on the best model is not a switch."""
|
||||||
|
outcome = app_service.resolve(
|
||||||
|
coding_request(mode=RoutingMode.AUTO, current_model="strong-model"))
|
||||||
|
|
||||||
|
assert outcome.switched is False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fallback over the real engine
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_fallback_keeps_an_assessed_model_even_though_a_better_one_exists(app_service) -> None:
|
||||||
|
"""weak-model IS usable (it has a real probe score), so Fallback stays put
|
||||||
|
where Auto would switch — the behavioural difference between the modes."""
|
||||||
|
outcome = app_service.resolve(coding_request(mode=RoutingMode.FALLBACK))
|
||||||
|
|
||||||
|
assert outcome.switched is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_rescues_a_model_the_engine_cannot_serve(app_service) -> None:
|
||||||
|
"""A model absent from the ranking (never assessed / unavailable) is exactly
|
||||||
|
the situation Fallback exists for."""
|
||||||
|
outcome = app_service.resolve(
|
||||||
|
coding_request(mode=RoutingMode.FALLBACK, current_model="ghost-model"))
|
||||||
|
|
||||||
|
assert outcome.switched is True
|
||||||
|
assert outcome.model == "strong-model"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Surface parity — the point of R03-T04/T05
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.mark.parametrize("surface", ["cowork", "co4e", "ai_edit"])
|
||||||
|
def test_every_surface_gets_the_same_decision(app_service, surface) -> None:
|
||||||
|
"""Chat, Co4E and AI-Edit used to hold three copies of this logic. Given the
|
||||||
|
same inputs they must now be indistinguishable."""
|
||||||
|
outcome = app_service.resolve(coding_request(surface=surface, mode=RoutingMode.AUTO))
|
||||||
|
|
||||||
|
assert outcome.switched is True
|
||||||
|
assert outcome.model == "strong-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_edit_pinned_task_type_reaches_the_engine(app_service) -> None:
|
||||||
|
"""AI-Edit pins "coding" instead of classifying; the engine must honour it
|
||||||
|
even when the instruction text reads like something else entirely."""
|
||||||
|
outcome = app_service.resolve(coding_request(
|
||||||
|
surface="ai_edit",
|
||||||
|
prompt="Write a poem about the ocean", # classifier would say "creative"
|
||||||
|
task_type="coding",
|
||||||
|
mode=RoutingMode.AUTO,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert outcome.task_type == "coding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None:
|
||||||
|
"""With no explicit mode, the service reads the per-workspace setting — the
|
||||||
|
lookup the widgets used to do themselves."""
|
||||||
|
ctx.config.data["routing"]["switch_mode"] = "auto"
|
||||||
|
|
||||||
|
outcome = app_service.resolve(coding_request())
|
||||||
|
|
||||||
|
assert outcome.mode is RoutingMode.AUTO
|
||||||
|
assert outcome.switched is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
|
||||||
|
"""The new mode must be persistable, or the toggle could never select it."""
|
||||||
|
ctx.config.set_routing_mode_for("cowork", "fallback")
|
||||||
|
|
||||||
|
assert ctx.config.routing_mode_for("cowork") == "fallback"
|
||||||
|
assert ctx.project_routing_mode("cowork") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
|
||||||
|
"""A hand-edited config must not enable routing by accident."""
|
||||||
|
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
||||||
|
|
||||||
|
assert ctx.config.routing_mode_for("cowork") == "off"
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""R04-T05 — the Schedule Task runner's cowork branch, pinned before and after.
|
||||||
|
|
||||||
|
Written against the CURRENT ``_run_agent`` first, as the safety net for moving it
|
||||||
|
onto ``ConversationApplicationService``: an unattended run has five behaviours the
|
||||||
|
interactive path does not have (the plan reminder prefixed to the prompt, the
|
||||||
|
session registered in History before the model starts, a re-save after every
|
||||||
|
assistant message, the timeout notice, and the "did the agent's own checklist
|
||||||
|
finish?" report), and none of them was covered by a test.
|
||||||
|
|
||||||
|
Everything is isolated from the user's real config: history goes to ``tmp_path``
|
||||||
|
via ``history.custom_dir`` and the AI guardrails are off, so no run touches
|
||||||
|
``~/.cowork_local`` or calls a model to review a prompt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
||||||
|
from cowork_local.core import task_executors
|
||||||
|
from cowork_local.tests.fakes.fake_provider import FakeProvider
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCtx:
|
||||||
|
"""The ``AppContext`` surface ``_run_agent`` touches."""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, provider: FakeProvider) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._provider = provider
|
||||||
|
|
||||||
|
def build_active_provider(self) -> FakeProvider:
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
def build_provider_for(self, name=None, model=None) -> FakeProvider:
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
|
||||||
|
def _config(tmp_path: Path) -> AppConfig:
|
||||||
|
data = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
# Keep the run entirely offline and off the real config dir.
|
||||||
|
data["agent_security"]["enabled"] = False
|
||||||
|
data["history"]["custom_dir"] = str(tmp_path / "history")
|
||||||
|
return AppConfig(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tmp_path: Path, provider: FakeProvider, *, prompt: str = "write the report",
|
||||||
|
timeout_sec: Optional[int] = None, admin_agent: Any = None):
|
||||||
|
"""Run one cowork task and return ``(result_tuple, events, config)``."""
|
||||||
|
out_dir = tmp_path / "run"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config = _config(tmp_path)
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
result = task_executors._run_agent(
|
||||||
|
_FakeCtx(config, provider), "cowork", prompt, out_dir,
|
||||||
|
events.append, lambda: False, title="Weekly report",
|
||||||
|
timeout_sec=timeout_sec, admin_agent=admin_agent,
|
||||||
|
)
|
||||||
|
return result, events, config
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_conversation(config: AppConfig) -> Dict[str, Any]:
|
||||||
|
"""The single conversation the run wrote into the isolated history folder."""
|
||||||
|
files = list(Path(config.history_dir()).rglob("*.json"))
|
||||||
|
assert len(files) == 1, f"expected one saved conversation, found {files}"
|
||||||
|
return json.loads(files[0].read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_a_cowork_task_returns_the_final_answer(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="Report is ready.")
|
||||||
|
|
||||||
|
(answer, timed_out, incomplete), _events, _config = _run(tmp_path, provider)
|
||||||
|
|
||||||
|
assert answer == "Report is ready."
|
||||||
|
assert timed_out is False
|
||||||
|
assert incomplete == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_plan_reminder_is_prefixed_to_the_prompt(tmp_path: Path) -> None:
|
||||||
|
# An unattended run has nobody watching, so the agent is pushed to keep its
|
||||||
|
# own checklist honest. The reminder must lead the message.
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="ok")
|
||||||
|
|
||||||
|
_run(tmp_path, provider, prompt="write the report")
|
||||||
|
|
||||||
|
sent = provider.call_history[0][-1]["content"]
|
||||||
|
assert sent.startswith("This runs unattended (Schedule Task)")
|
||||||
|
assert sent.endswith("write the report")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_admin_agent_persona_sits_between_the_reminder_and_the_prompt(
|
||||||
|
tmp_path: Path) -> None:
|
||||||
|
class _Agent:
|
||||||
|
# An admin agent may pin its own provider/model; blank means "use the
|
||||||
|
# machine's Settings default", which is what build_agent_provider reads.
|
||||||
|
provider = ""
|
||||||
|
model = ""
|
||||||
|
|
||||||
|
def effective_prompt(self) -> str:
|
||||||
|
return "You are the reporting agent."
|
||||||
|
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="ok")
|
||||||
|
|
||||||
|
_run(tmp_path, provider, prompt="write the report", admin_agent=_Agent())
|
||||||
|
|
||||||
|
sent = provider.call_history[0][-1]["content"]
|
||||||
|
assert sent.index("This runs unattended") < sent.index("You are the reporting agent.")
|
||||||
|
assert sent.index("You are the reporting agent.") < sent.index("write the report")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_session_is_announced_once_it_exists_on_disk(tmp_path: Path) -> None:
|
||||||
|
# The scheduler refreshes History on this event, so it must not fire before
|
||||||
|
# the conversation is really there.
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="ok")
|
||||||
|
|
||||||
|
_result, events, config = _run(tmp_path, provider)
|
||||||
|
|
||||||
|
ready = [e for e in events if e["type"] == "history_ready"]
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0]["session_id"]
|
||||||
|
assert _saved_conversation(config)["session_id"] == ready[0]["session_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_saved_conversation_carries_the_answer_and_the_task_title(
|
||||||
|
tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="Report is ready.")
|
||||||
|
|
||||||
|
_result, _events, config = _run(tmp_path, provider)
|
||||||
|
|
||||||
|
saved = _saved_conversation(config)
|
||||||
|
assert saved["title"] == "[Task] Weekly report"
|
||||||
|
assert saved["messages"][-1] == {"role": "assistant", "content": "Report is ready."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unfinished_checklist_is_reported_back_to_the_scheduler(
|
||||||
|
tmp_path: Path) -> None:
|
||||||
|
# The agent ticked no step to done, so the task must not be called finished
|
||||||
|
# just because no exception was raised.
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(
|
||||||
|
content="Working on it.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "update_plan",
|
||||||
|
"arguments": {"steps": [{"title": "Draft", "status": "running"}]}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="Stopping here.")
|
||||||
|
|
||||||
|
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
|
||||||
|
|
||||||
|
assert incomplete
|
||||||
|
assert "Draft" in incomplete
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finished_checklist_reports_nothing_outstanding(tmp_path: Path) -> None:
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(
|
||||||
|
content="Done.",
|
||||||
|
tool_calls=[{"id": "c1", "name": "update_plan",
|
||||||
|
"arguments": {"steps": [{"title": "Draft", "status": "done"}]}}],
|
||||||
|
)
|
||||||
|
provider.queue_response(content="All done.")
|
||||||
|
|
||||||
|
(_answer, _timed_out, incomplete), _events, _config = _run(tmp_path, provider)
|
||||||
|
|
||||||
|
assert incomplete == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_out_of_time_appends_the_timeout_notice_to_the_conversation(
|
||||||
|
tmp_path: Path) -> None:
|
||||||
|
# A negative timeout puts the deadline in the past, which is the only
|
||||||
|
# deterministic way to exercise a wall-clock branch in a unit test.
|
||||||
|
provider = FakeProvider()
|
||||||
|
provider.queue_response(content="never gets there")
|
||||||
|
|
||||||
|
(answer, timed_out, incomplete), events, config = _run(
|
||||||
|
tmp_path, provider, timeout_sec=-1)
|
||||||
|
|
||||||
|
assert timed_out is True
|
||||||
|
assert incomplete == "" # a timeout is not an unfinished checklist
|
||||||
|
assert "quá thời gian chờ" in answer
|
||||||
|
assert any(e["type"] == "assistant_done" and "quá thời gian chờ" in e["content"]
|
||||||
|
for e in events)
|
||||||
|
assert "quá thời gian chờ" in _saved_conversation(config)["messages"][-1]["content"]
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
"""Pytest fixtures/shared helpers for the routing test suite.
|
"""Pytest fixtures/shared helpers for the routing test suite.
|
||||||
|
|
||||||
Ensures the ``cowork_local`` package is importable when pytest is invoked from
|
Package importability is handled once and for all by ``tests/conftest.py``,
|
||||||
the package directory itself (so ``import cowork_local.core.routing...`` works
|
which binds THIS checkout to the ``cowork_local`` name in ``sys.modules``.
|
||||||
regardless of the working directory the suite is launched from).
|
This file used to push the checkout's PARENT directory onto ``sys.path``, which
|
||||||
|
let an unrelated sibling folder named ``cowork_local`` shadow the working copy —
|
||||||
|
so that logic is intentionally gone; keep it that way.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# .../cowork_local/tests/routing/conftest.py → parent of the package dir
|
|
||||||
_PKG_DIR = Path(__file__).resolve().parents[2] # .../cowork_local
|
|
||||||
_REPO_ROOT = _PKG_DIR.parent # .../cowork_local_20260722
|
|
||||||
for p in (str(_REPO_ROOT), str(_PKG_DIR)):
|
|
||||||
if p not in sys.path:
|
|
||||||
sys.path.insert(0, p)
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user