CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
178 lines
7.7 KiB
Python
178 lines
7.7 KiB
Python
"""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",
|
|
]
|