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