Compare commits
1 Commits
33a9845566
...
personal-u
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa1249403 |
@ -7,8 +7,8 @@ BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
|
||||
BEAVER_DEPLOY_TOKEN=change-me
|
||||
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
|
||||
|
||||
BEAVER_SERVER_IP=203.0.113.10
|
||||
BEAVER_BASE_DOMAIN=203.0.113.10.nip.io
|
||||
BEAVER_SERVER_IP=127.0.0.1
|
||||
BEAVER_BASE_DOMAIN=localhost
|
||||
|
||||
BEAVER_PROVIDER=openai
|
||||
BEAVER_MODEL=openai/gpt-5
|
||||
|
||||
@ -72,7 +72,7 @@ export BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
|
||||
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
||||
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
||||
|
||||
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
|
||||
export BEAVER_BASE_DOMAIN=localhost
|
||||
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
|
||||
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
|
||||
|
||||
@ -110,14 +110,14 @@ http://beaver-authz-service:19090
|
||||
|
||||
```bash
|
||||
DEPLOY_PUBLIC_SCHEME=http
|
||||
DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io
|
||||
DEPLOY_PUBLIC_BASE_DOMAIN=localhost
|
||||
DEPLOY_PUBLIC_PORT=8088
|
||||
```
|
||||
|
||||
本机测试时实例 URL 形如:
|
||||
|
||||
```text
|
||||
http://alice.127.0.0.1.nip.io:8088
|
||||
http://alice.localhost:8088
|
||||
```
|
||||
|
||||
正式 HTTPS 域名通常改成:
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "researcher",
|
||||
"capabilities": [
|
||||
"research",
|
||||
"analysis",
|
||||
"source review",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756341+00:00",
|
||||
"description": "Finds facts, references, constraints, and implementation options.",
|
||||
"display_name": "Researcher",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "researcher",
|
||||
"priority": 50,
|
||||
"provider_name": null,
|
||||
"role": "research",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
||||
"tags": [
|
||||
"planning",
|
||||
"research"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756349+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "implementer",
|
||||
"capabilities": [
|
||||
"implementation",
|
||||
"coding",
|
||||
"refactor",
|
||||
"integration"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756351+00:00",
|
||||
"description": "Builds scoped implementation slices and proposes concrete changes.",
|
||||
"display_name": "Implementer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "implementer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "implementation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
|
||||
"tags": [
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756353+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "reviewer",
|
||||
"capabilities": [
|
||||
"review",
|
||||
"quality",
|
||||
"risk",
|
||||
"verification"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756355+00:00",
|
||||
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
|
||||
"display_name": "Reviewer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "reviewer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "review",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
|
||||
"tags": [
|
||||
"review",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756356+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "tester",
|
||||
"capabilities": [
|
||||
"testing",
|
||||
"verification",
|
||||
"regression",
|
||||
"qa"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756358+00:00",
|
||||
"description": "Designs and executes verification checks for task outputs.",
|
||||
"display_name": "Tester",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "tester",
|
||||
"priority": 40,
|
||||
"provider_name": null,
|
||||
"role": "testing",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
||||
"tags": [
|
||||
"test",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756358+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "documenter",
|
||||
"capabilities": [
|
||||
"documentation",
|
||||
"explanation",
|
||||
"migration notes",
|
||||
"release notes"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756360+00:00",
|
||||
"description": "Writes and reconciles user-facing and internal documentation updates.",
|
||||
"display_name": "Documenter",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "documenter",
|
||||
"priority": 35,
|
||||
"provider_name": null,
|
||||
"role": "documentation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
||||
"tags": [
|
||||
"docs",
|
||||
"communication"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756360+00:00"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@ -18,9 +18,8 @@ if TYPE_CHECKING:
|
||||
class TeamGraphScheduler:
|
||||
"""Execute sequence, parallel, and DAG team graphs."""
|
||||
|
||||
def __init__(self, runner: LocalAgentRunner, *, max_parallel_team_nodes: int = 3) -> None:
|
||||
def __init__(self, runner: LocalAgentRunner) -> None:
|
||||
self.runner = runner
|
||||
self.max_parallel_team_nodes = max(1, int(max_parallel_team_nodes))
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@ -97,18 +96,7 @@ class TeamGraphScheduler:
|
||||
nodes: list[ExecutionNode],
|
||||
**kwargs,
|
||||
) -> list[NodeRunResult]:
|
||||
semaphore = asyncio.Semaphore(self.max_parallel_team_nodes)
|
||||
|
||||
async def run_one(node: ExecutionNode) -> NodeRunResult:
|
||||
async with semaphore:
|
||||
return await self._run_node(
|
||||
node,
|
||||
dependency_outputs={},
|
||||
execution_mode="isolated_loop",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return list(await asyncio.gather(*(run_one(node) for node in nodes)))
|
||||
return list(await asyncio.gather(*(self._run_node(node, dependency_outputs={}, **kwargs) for node in nodes)))
|
||||
|
||||
async def _run_dag(
|
||||
self,
|
||||
@ -176,7 +164,6 @@ class TeamGraphScheduler:
|
||||
inherited_pinned_skill_contexts: list["SkillContext"],
|
||||
allow_candidate_generation: bool,
|
||||
dependency_outputs: dict[str, str],
|
||||
execution_mode: str = "shared_loop",
|
||||
) -> NodeRunResult:
|
||||
try:
|
||||
pinned = self._merge_pinned(inherited_pinned_skills, node.inherited_pinned_skills)
|
||||
@ -202,7 +189,6 @@ class TeamGraphScheduler:
|
||||
envelope,
|
||||
provider_bundle=node_provider_bundle,
|
||||
allow_candidate_generation=allow_candidate_generation,
|
||||
execution_mode=execution_mode,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@ -255,7 +241,7 @@ class TeamGraphScheduler:
|
||||
failed = [item for item in results if not item.success]
|
||||
if failed:
|
||||
failure_lines = [
|
||||
f"- {item.node_id}: {item.error or item.finish_reason} evidence={'yes' if item.evidence else 'no'}"
|
||||
f"- {item.node_id}: {item.error or item.finish_reason}"
|
||||
for item in failed
|
||||
]
|
||||
summary_parts.append("Failed nodes:\n" + "\n".join(failure_lines))
|
||||
|
||||
@ -6,7 +6,6 @@ from uuid import uuid4
|
||||
|
||||
from beaver.engine import AgentLoop
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
from beaver.tasks.evidence import EvidenceBuilder
|
||||
|
||||
from .models import DelegationEnvelope, NodeRunResult
|
||||
|
||||
@ -23,7 +22,6 @@ class LocalAgentRunner:
|
||||
*,
|
||||
provider_bundle: ProviderBundle | None = None,
|
||||
allow_candidate_generation: bool = False,
|
||||
execution_mode: str = "shared_loop",
|
||||
) -> NodeRunResult:
|
||||
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
|
||||
raise ValueError(
|
||||
@ -31,14 +29,7 @@ class LocalAgentRunner:
|
||||
"build a node-specific provider bundle instead."
|
||||
)
|
||||
child_session_id = self._child_session_id(envelope)
|
||||
target_loop = self.loop
|
||||
if execution_mode == "isolated_loop":
|
||||
target_loop = AgentLoop(profile=self.loop.profile, loader=self.loop.loader)
|
||||
runner = (
|
||||
target_loop.process_direct
|
||||
if execution_mode == "isolated_loop"
|
||||
else (self.loop.submit_direct if self.loop.is_running else self.loop.process_direct)
|
||||
)
|
||||
runner = self.loop.submit_direct if self.loop.is_running else self.loop.process_direct
|
||||
result = await runner(
|
||||
envelope.task,
|
||||
session_id=child_session_id,
|
||||
@ -56,13 +47,6 @@ class LocalAgentRunner:
|
||||
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
|
||||
allow_candidate_generation=allow_candidate_generation,
|
||||
)
|
||||
loaded = target_loop.boot()
|
||||
evidence = EvidenceBuilder(loaded.session_manager).build_run_evidence(
|
||||
result.session_id,
|
||||
result.run_id,
|
||||
result.output_text,
|
||||
result.finish_reason,
|
||||
)
|
||||
success = result.finish_reason == "stop"
|
||||
return NodeRunResult(
|
||||
node_id=envelope.node_id or envelope.agent.name,
|
||||
@ -72,7 +56,6 @@ class LocalAgentRunner:
|
||||
session_id=result.session_id,
|
||||
finish_reason=result.finish_reason,
|
||||
error=None if success else (result.output_text or result.finish_reason),
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beaver.engine.context import SkillContext
|
||||
from beaver.tasks.evidence import RunEvidence
|
||||
|
||||
|
||||
TeamStrategy = Literal[
|
||||
@ -117,7 +116,6 @@ class NodeRunResult:
|
||||
session_id: str | None = None
|
||||
finish_reason: str = "stop"
|
||||
error: str | None = None
|
||||
evidence: "RunEvidence | None" = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@ -128,7 +126,6 @@ class NodeRunResult:
|
||||
"session_id": self.session_id,
|
||||
"finish_reason": self.finish_reason,
|
||||
"error": self.error,
|
||||
"evidence": self.evidence.to_dict() if self.evidence is not None else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ from .builder import (
|
||||
ContextBuildInput,
|
||||
ContextBuildResult,
|
||||
ContextBuilder,
|
||||
RuntimeContext,
|
||||
SessionContext,
|
||||
SkillContext,
|
||||
)
|
||||
@ -13,7 +12,6 @@ __all__ = [
|
||||
"ContextBuildInput",
|
||||
"ContextBuildResult",
|
||||
"ContextBuilder",
|
||||
"RuntimeContext",
|
||||
"SessionContext",
|
||||
"SkillContext",
|
||||
]
|
||||
|
||||
@ -80,16 +80,6 @@ class SessionContext:
|
||||
parent_session_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeContext:
|
||||
"""Per-run runtime facts that should be visible to the model."""
|
||||
|
||||
utc_datetime: str
|
||||
local_datetime: str
|
||||
timezone: str | None = None
|
||||
utc_offset: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextBuildInput:
|
||||
"""一次上下文构建所需的全部输入。
|
||||
@ -113,7 +103,6 @@ class ContextBuildInput:
|
||||
memory_snapshot: MemorySnapshot | None = None
|
||||
activated_skills: list[SkillContext] = field(default_factory=list)
|
||||
session_context: SessionContext | None = None
|
||||
runtime_context: RuntimeContext | None = None
|
||||
execution_context: str | None = None
|
||||
extra_sections: list[str] = field(default_factory=list)
|
||||
|
||||
@ -154,10 +143,9 @@ class ContextBuilder:
|
||||
1. Beaver user-facing assistant identity
|
||||
2. base system prompt
|
||||
3. session metadata
|
||||
4. runtime date/time
|
||||
5. execution context
|
||||
6. frozen memory snapshot
|
||||
7. extra sections
|
||||
4. execution context
|
||||
5. frozen memory snapshot
|
||||
6. extra sections
|
||||
|
||||
这样设计的原因:
|
||||
- 身份与总规则要最靠前
|
||||
@ -176,10 +164,6 @@ class ContextBuilder:
|
||||
if session_section:
|
||||
sections.append(session_section)
|
||||
|
||||
runtime_section = self._render_runtime_section(build_input.runtime_context)
|
||||
if runtime_section:
|
||||
sections.append(runtime_section)
|
||||
|
||||
execution_context = (build_input.execution_context or "").strip()
|
||||
if execution_context:
|
||||
sections.append(f"# Execution Context\n\n{execution_context}")
|
||||
@ -363,31 +347,6 @@ class ContextBuilder:
|
||||
return None
|
||||
return "# Current Session\n\n" + "\n".join(rows)
|
||||
|
||||
def _render_runtime_section(self, runtime_context: RuntimeContext | None) -> str | None:
|
||||
"""Render date/time facts captured for the current model run."""
|
||||
|
||||
if runtime_context is None:
|
||||
return None
|
||||
|
||||
rows: list[str] = []
|
||||
if runtime_context.utc_datetime:
|
||||
rows.append(f"Current UTC time: {runtime_context.utc_datetime}")
|
||||
if runtime_context.local_datetime:
|
||||
rows.append(f"Current local time: {runtime_context.local_datetime}")
|
||||
if runtime_context.timezone:
|
||||
rows.append(f"Local timezone: {runtime_context.timezone}")
|
||||
if runtime_context.utc_offset:
|
||||
rows.append(f"Local UTC offset: {runtime_context.utc_offset}")
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
return (
|
||||
"# Current Date and Time\n\n"
|
||||
+ "\n".join(rows)
|
||||
+ "\n\nUse this section as authoritative for relative date/time references such as "
|
||||
'"today", "tomorrow", "now", "this week", and "next month".'
|
||||
)
|
||||
|
||||
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
|
||||
"""把已激活 skill 转成显式消息。
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ from beaver.skills.learning.eval import SkillDraftEvaluator
|
||||
from beaver.skills.publisher import SkillPublisher
|
||||
from beaver.skills.reviews import ReviewService
|
||||
from beaver.skills.specs import SkillSpecStore
|
||||
from beaver.tasks import TaskExecutionPlanner, TaskService
|
||||
from beaver.tasks import TaskExecutionPlanner, TaskService, ValidationService
|
||||
from beaver.tasks.skill_resolver import TaskSkillResolver
|
||||
from beaver.skills import SkillAssembler, SkillsLoader
|
||||
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
||||
@ -44,10 +44,15 @@ from beaver.tools.builtins import (
|
||||
SpawnTool,
|
||||
SessionSearchTool,
|
||||
SkillManageTool,
|
||||
SkillViewTool,
|
||||
SkillsListTool,
|
||||
TerminalTool,
|
||||
TodoTool,
|
||||
UserFilesCopyToWorkspaceTool,
|
||||
UserFilesListTool,
|
||||
UserFilesMkdirTool,
|
||||
UserFilesPublishOutputTool,
|
||||
UserFilesReadTool,
|
||||
UserFilesWriteTool,
|
||||
WebFetchTool,
|
||||
WebSearchTool,
|
||||
WriteFileTool,
|
||||
@ -92,6 +97,7 @@ class EngineLoadResult:
|
||||
task_skill_resolver: TaskSkillResolver | None = None
|
||||
task_service: TaskService | None = None
|
||||
task_execution_planner: TaskExecutionPlanner | None = None
|
||||
validation_service: ValidationService | None = None
|
||||
mcp_manager: MCPConnectionManager | None = None
|
||||
mcp_report: dict[str, dict] = field(default_factory=dict)
|
||||
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
|
||||
@ -166,6 +172,7 @@ class EngineLoader:
|
||||
task_skill_resolver: TaskSkillResolver | None = None,
|
||||
task_service: TaskService | None = None,
|
||||
task_execution_planner: TaskExecutionPlanner | None = None,
|
||||
validation_service: ValidationService | None = None,
|
||||
) -> None:
|
||||
self.config = config or load_config(workspace=workspace, config_path=config_path)
|
||||
configured_workspace = self.config.agents_defaults.workspace
|
||||
@ -191,6 +198,7 @@ class EngineLoader:
|
||||
self._task_skill_resolver = task_skill_resolver
|
||||
self._task_service = task_service
|
||||
self._task_execution_planner = task_execution_planner
|
||||
self._validation_service = validation_service
|
||||
|
||||
def load(self) -> EngineLoadResult:
|
||||
"""装配当前主链需要的最小 runtime 对象。"""
|
||||
@ -220,18 +228,23 @@ class EngineLoader:
|
||||
ObjectBackedTool(SearchFilesTool()),
|
||||
ObjectBackedTool(WriteFileTool()),
|
||||
ObjectBackedTool(PatchFileTool()),
|
||||
ObjectBackedTool(UserFilesListTool()),
|
||||
ObjectBackedTool(UserFilesReadTool()),
|
||||
ObjectBackedTool(UserFilesWriteTool()),
|
||||
ObjectBackedTool(UserFilesMkdirTool()),
|
||||
ObjectBackedTool(UserFilesCopyToWorkspaceTool()),
|
||||
ObjectBackedTool(UserFilesPublishOutputTool()),
|
||||
ObjectBackedTool(WebFetchTool()),
|
||||
ObjectBackedTool(WebSearchTool()),
|
||||
ObjectBackedTool(TerminalTool()),
|
||||
ObjectBackedTool(ProcessTool()),
|
||||
ObjectBackedTool(ExecuteCodeTool()),
|
||||
ObjectBackedTool(TodoTool()),
|
||||
ObjectBackedTool(ClarifyTool()),
|
||||
ObjectBackedTool(SendMessageTool()),
|
||||
ObjectBackedTool(DelegateTool()),
|
||||
ObjectBackedTool(SpawnTool()),
|
||||
SkillsListTool(),
|
||||
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
||||
ObjectBackedTool(WebSearchTool()),
|
||||
ObjectBackedTool(TerminalTool()),
|
||||
ObjectBackedTool(ProcessTool()),
|
||||
ObjectBackedTool(ExecuteCodeTool()),
|
||||
ObjectBackedTool(TodoTool()),
|
||||
ObjectBackedTool(ClarifyTool()),
|
||||
ObjectBackedTool(SendMessageTool()),
|
||||
ObjectBackedTool(DelegateTool()),
|
||||
ObjectBackedTool(SpawnTool()),
|
||||
SkillsListTool(),
|
||||
SkillManageTool(),
|
||||
CronTool(),
|
||||
]
|
||||
@ -275,6 +288,7 @@ class EngineLoader:
|
||||
)
|
||||
task_service = self._task_service or TaskService(workspace / "tasks")
|
||||
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
|
||||
validation_service = self._validation_service or ValidationService()
|
||||
mcp_manager = MCPConnectionManager(
|
||||
self.config.tools.mcp_servers,
|
||||
authz_config=self.config.authz,
|
||||
@ -309,6 +323,7 @@ class EngineLoader:
|
||||
task_skill_resolver=task_skill_resolver,
|
||||
task_service=task_service,
|
||||
task_execution_planner=task_execution_planner,
|
||||
validation_service=validation_service,
|
||||
mcp_manager=mcp_manager,
|
||||
)
|
||||
if self._session_manager is None:
|
||||
|
||||
@ -4,15 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
|
||||
from beaver.engine.context import ContextBuildInput, SessionContext, SkillContext
|
||||
from beaver.memory.runs import RunRecord, SkillEffectRecord
|
||||
from beaver.skills.learning import RunReceiptContext
|
||||
from beaver.skills.catalog.utils import strip_frontmatter
|
||||
@ -29,17 +26,6 @@ TOOL_FAILURE_GUIDANCE_PROMPT = (
|
||||
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
|
||||
)
|
||||
|
||||
RAW_TOOL_CALL_FALLBACK = (
|
||||
"The run reached the configured tool-call limit before producing a reliable final answer. "
|
||||
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
|
||||
"Please request a revision to continue the task."
|
||||
)
|
||||
|
||||
_RAW_TOOL_CALL_RE = re.compile(
|
||||
r"^\s*<tool_call\b[\s\S]*?</tool_call>\s*$|^\s*<function=[^>]+>[\s\S]*?</function>\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentProfile:
|
||||
@ -48,10 +34,9 @@ class AgentProfile:
|
||||
name: str = "default"
|
||||
system_prompt: str = ""
|
||||
default_model: str = "gpt-4.1-mini"
|
||||
max_tokens: int | None = None
|
||||
max_context_messages: int = 1000
|
||||
max_tokens: int = 4096
|
||||
temperature: float = 0.2
|
||||
max_tool_iterations: int = 30
|
||||
max_tool_iterations: int = 8
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -89,7 +74,6 @@ class AgentLoop:
|
||||
self.loaded: EngineLoadResult | None = None
|
||||
self.runtime_services: dict[str, Any] = {}
|
||||
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
||||
self._active_direct_task: asyncio.Task[Any] | None = None
|
||||
self._running = False
|
||||
self._stop_requested = False
|
||||
|
||||
@ -131,8 +115,6 @@ class AgentLoop:
|
||||
if item.future.cancelled():
|
||||
continue
|
||||
|
||||
previous_direct_task = self._active_direct_task
|
||||
self._active_direct_task = asyncio.current_task()
|
||||
try:
|
||||
result = await self._process_direct_impl(item.task, **item.kwargs)
|
||||
except asyncio.CancelledError:
|
||||
@ -145,8 +127,6 @@ class AgentLoop:
|
||||
else:
|
||||
if not item.future.done():
|
||||
item.future.set_result(result)
|
||||
finally:
|
||||
self._active_direct_task = previous_direct_task
|
||||
finally:
|
||||
if self._run_queue is not None:
|
||||
while True:
|
||||
@ -188,9 +168,6 @@ class AgentLoop:
|
||||
if self._stop_requested:
|
||||
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
|
||||
|
||||
if asyncio.current_task() is self._active_direct_task:
|
||||
return await self._process_direct_impl(task, **kwargs)
|
||||
|
||||
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
|
||||
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
||||
return await future
|
||||
@ -371,7 +348,7 @@ class AgentLoop:
|
||||
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
||||
resolved_embedding_model = embedding_model or config.default_embedding_model
|
||||
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
||||
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
|
||||
resolved_max_tokens = max_tokens or self.profile.max_tokens
|
||||
resolved_temperature = self.profile.temperature if temperature is None else temperature
|
||||
resolved_max_tool_iterations = (
|
||||
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
|
||||
@ -469,7 +446,7 @@ class AgentLoop:
|
||||
*(pinned_skill_contexts or []),
|
||||
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
|
||||
]
|
||||
if not include_skill_assembly:
|
||||
if not include_skill_assembly or thinking_enabled is False:
|
||||
activated_skills = self._merge_skill_contexts(pinned_skills, [])
|
||||
else:
|
||||
skill_query = skill_selection_context or task
|
||||
@ -535,6 +512,8 @@ class AgentLoop:
|
||||
|
||||
if not include_tools:
|
||||
selected_tool_specs = []
|
||||
elif thinking_enabled is False:
|
||||
selected_tool_specs = tool_registry.list_specs()
|
||||
else:
|
||||
selected_tool_specs = await tool_assembler.assemble(
|
||||
task_description=task,
|
||||
@ -564,10 +543,7 @@ class AgentLoop:
|
||||
|
||||
build_input = ContextBuildInput(
|
||||
base_system_prompt=self.profile.system_prompt,
|
||||
history=session_manager.get_history(
|
||||
resolved_session_id,
|
||||
max_messages=max(1, self.profile.max_context_messages),
|
||||
),
|
||||
history=session_manager.get_history(resolved_session_id),
|
||||
current_user_input=task,
|
||||
memory_snapshot=memory_snapshot,
|
||||
activated_skills=activated_skills,
|
||||
@ -578,7 +554,6 @@ class AgentLoop:
|
||||
user_id=user_id,
|
||||
parent_session_id=parent_session_id,
|
||||
),
|
||||
runtime_context=self._current_runtime_context(),
|
||||
execution_context=execution_context,
|
||||
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
|
||||
)
|
||||
@ -646,11 +621,17 @@ class AgentLoop:
|
||||
"tool_registry": tool_registry,
|
||||
"skills_loader": skills_loader,
|
||||
"draft_service": getattr(loaded, "draft_service", None),
|
||||
"beaver_config": loaded.config,
|
||||
"task_id": task_id,
|
||||
"run_id": resolved_run_id,
|
||||
**self.runtime_services,
|
||||
},
|
||||
metadata={
|
||||
"source": source,
|
||||
"agent_name": self.profile.name,
|
||||
"session_id": resolved_session_id,
|
||||
"task_id": task_id,
|
||||
"run_id": resolved_run_id,
|
||||
},
|
||||
)
|
||||
|
||||
@ -662,39 +643,36 @@ class AgentLoop:
|
||||
while True:
|
||||
chat_kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"tools": tool_schemas if include_tools else None,
|
||||
"tools": tool_schemas,
|
||||
"model": final_model,
|
||||
"max_tokens": resolved_max_tokens,
|
||||
"temperature": resolved_temperature,
|
||||
}
|
||||
if thinking_enabled is not None:
|
||||
chat_kwargs["thinking_enabled"] = thinking_enabled
|
||||
message_char_length = len(json.dumps(messages, ensure_ascii=False, default=str))
|
||||
tool_schema_char_length = len(json.dumps(tool_schemas, ensure_ascii=False, default=str))
|
||||
tool_names = [
|
||||
str(tool.get("function", {}).get("name") or tool.get("name") or "tool")
|
||||
for tool in (tool_schemas or [])
|
||||
if isinstance(tool, dict)
|
||||
]
|
||||
snapshot_payload = {
|
||||
"iteration": iterations,
|
||||
"provider_name": final_provider_name,
|
||||
"model": final_model,
|
||||
"message_count": len(messages),
|
||||
"tool_names": tool_names,
|
||||
"message_char_length": message_char_length,
|
||||
"tool_schema_char_length": tool_schema_char_length,
|
||||
"max_tokens": resolved_max_tokens,
|
||||
"temperature": resolved_temperature,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
}
|
||||
session_manager.append_message(
|
||||
resolved_session_id,
|
||||
run_id=resolved_run_id,
|
||||
role="system",
|
||||
event_type="llm_request_snapshotted",
|
||||
event_payload=snapshot_payload,
|
||||
content=json.dumps(snapshot_payload, ensure_ascii=False, default=str),
|
||||
event_payload={
|
||||
"iteration": iterations,
|
||||
"provider_name": final_provider_name,
|
||||
"model": final_model,
|
||||
"messages": messages,
|
||||
"tools": tool_schemas,
|
||||
"max_tokens": resolved_max_tokens,
|
||||
"temperature": resolved_temperature,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
},
|
||||
content=json.dumps(
|
||||
{
|
||||
"messages": messages,
|
||||
"tools": tool_schemas,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
context_visible=False,
|
||||
source=source,
|
||||
title=title,
|
||||
@ -718,7 +696,6 @@ class AgentLoop:
|
||||
tool_calls=assistant_tool_calls or None,
|
||||
finish_reason=response.finish_reason,
|
||||
reasoning=response.reasoning_content,
|
||||
context_visible=not bool(assistant_tool_calls),
|
||||
source=source,
|
||||
title=title,
|
||||
model=final_model,
|
||||
@ -733,24 +710,12 @@ class AgentLoop:
|
||||
|
||||
if not response.has_tool_calls:
|
||||
final_text = response.content or ""
|
||||
if self._looks_like_raw_tool_call(final_text):
|
||||
final_text = RAW_TOOL_CALL_FALLBACK
|
||||
final_finish_reason = "invalid_tool_call_text"
|
||||
else:
|
||||
final_finish_reason = response.finish_reason or "stop"
|
||||
final_finish_reason = response.finish_reason or "stop"
|
||||
break
|
||||
|
||||
if iterations >= resolved_max_tool_iterations:
|
||||
finalized = await self._finalize_after_tool_limit(
|
||||
provider=provider,
|
||||
messages=messages,
|
||||
model=final_model,
|
||||
max_tokens=resolved_max_tokens,
|
||||
temperature=resolved_temperature,
|
||||
thinking_enabled=thinking_enabled,
|
||||
)
|
||||
final_text = finalized or RAW_TOOL_CALL_FALLBACK
|
||||
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
|
||||
final_text = response.content or "Tool loop stopped after reaching the configured iteration limit."
|
||||
final_finish_reason = "max_tool_iterations"
|
||||
session_manager.append_message(
|
||||
resolved_session_id,
|
||||
run_id=resolved_run_id,
|
||||
@ -894,56 +859,6 @@ class AgentLoop:
|
||||
raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
async def _finalize_after_tool_limit(
|
||||
*,
|
||||
provider: Any,
|
||||
messages: list[dict[str, Any]],
|
||||
model: str,
|
||||
max_tokens: int | None,
|
||||
temperature: float,
|
||||
thinking_enabled: bool | None,
|
||||
) -> str:
|
||||
final_messages = AgentLoop._with_system_guidance(
|
||||
messages,
|
||||
(
|
||||
"The configured tool iteration budget is exhausted. Do not call tools. "
|
||||
"Produce the best final answer from the existing conversation and tool results. "
|
||||
"State uncertainty explicitly."
|
||||
),
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"messages": final_messages,
|
||||
"tools": None,
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if thinking_enabled is not None:
|
||||
kwargs["thinking_enabled"] = thinking_enabled
|
||||
response = await provider.chat(**kwargs)
|
||||
if response.has_tool_calls:
|
||||
return ""
|
||||
content = (response.content or "").strip()
|
||||
if AgentLoop._looks_like_raw_tool_call(content):
|
||||
return ""
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_raw_tool_call(content: str | None) -> bool:
|
||||
if not content:
|
||||
return False
|
||||
return bool(_RAW_TOOL_CALL_RE.match(content))
|
||||
|
||||
@staticmethod
|
||||
def _with_system_guidance(messages: list[dict[str, Any]], guidance: str) -> list[dict[str, Any]]:
|
||||
copied = [dict(message) for message in messages]
|
||||
if copied and copied[0].get("role") == "system":
|
||||
existing = str(copied[0].get("content") or "").strip()
|
||||
copied[0]["content"] = "\n\n".join(part for part in (existing, guidance.strip()) if part)
|
||||
return copied
|
||||
return [{"role": "system", "content": guidance.strip()}, *copied]
|
||||
|
||||
@staticmethod
|
||||
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
|
||||
contexts: list[SkillContext] = []
|
||||
@ -1177,49 +1092,3 @@ class AgentLoop:
|
||||
@staticmethod
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _current_runtime_context() -> RuntimeContext:
|
||||
utc_now = datetime.now(timezone.utc)
|
||||
timezone_name = AgentLoop._configured_timezone_name()
|
||||
local_now = datetime.now().astimezone()
|
||||
rendered_timezone = local_now.tzname()
|
||||
|
||||
if timezone_name:
|
||||
try:
|
||||
local_now = utc_now.astimezone(ZoneInfo(timezone_name))
|
||||
rendered_timezone = timezone_name
|
||||
except ZoneInfoNotFoundError:
|
||||
rendered_timezone = local_now.tzname() or timezone_name
|
||||
|
||||
return RuntimeContext(
|
||||
utc_datetime=utc_now.isoformat(),
|
||||
local_datetime=local_now.isoformat(),
|
||||
timezone=rendered_timezone,
|
||||
utc_offset=AgentLoop._format_utc_offset(local_now),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _configured_timezone_name() -> str | None:
|
||||
for value in (os.getenv("BEAVER_RUNTIME_TIMEZONE"), os.getenv("TZ")):
|
||||
cleaned = (value or "").strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
|
||||
try:
|
||||
timezone_file = "/etc/timezone"
|
||||
if os.path.exists(timezone_file):
|
||||
with open(timezone_file, encoding="utf-8") as file:
|
||||
cleaned = file.read().strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_utc_offset(value: datetime) -> str | None:
|
||||
raw = value.strftime("%z")
|
||||
if not raw:
|
||||
return None
|
||||
return f"{raw[:3]}:{raw[3:]}"
|
||||
|
||||
@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -57,14 +57,9 @@ class AnthropicProvider(LLMProvider):
|
||||
"model": model or self.default_model,
|
||||
"system": system_prompt or "",
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
resolved_max_tokens = (
|
||||
_default_max_tokens_for_model(model or self.default_model)
|
||||
if max_tokens is None
|
||||
else max(1, max_tokens)
|
||||
)
|
||||
kwargs["max_tokens"] = resolved_max_tokens
|
||||
if tools:
|
||||
kwargs["tools"] = _convert_tools(tools)
|
||||
|
||||
@ -105,17 +100,6 @@ class AnthropicProvider(LLMProvider):
|
||||
return self.default_model
|
||||
|
||||
|
||||
def _default_max_tokens_for_model(model: str) -> int:
|
||||
"""Return a conservative native output ceiling for Anthropic Messages."""
|
||||
|
||||
normalized = model.lower().replace("_", "-")
|
||||
if "sonnet-4" in normalized or "opus-4" in normalized or "3-7" in normalized or "3.7" in normalized:
|
||||
return 64_000
|
||||
if "haiku" in normalized:
|
||||
return 4_096
|
||||
return 8_192
|
||||
|
||||
|
||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_prompt = ""
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
||||
@ -88,7 +88,7 @@ class LLMProvider(ABC):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
model: str,
|
||||
max_tokens: int | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
thinking_enabled: bool | None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -55,10 +55,9 @@ class CustomProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.default_model,
|
||||
"messages": self.sanitize_empty_content(messages),
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max(1, max_tokens)
|
||||
if tools:
|
||||
kwargs.update(tools=tools, tool_choice="auto")
|
||||
try:
|
||||
|
||||
@ -23,7 +23,7 @@ except ModuleNotFoundError: # pragma: no cover
|
||||
litellm = None # type: ignore[assignment]
|
||||
acompletion = None # type: ignore[assignment]
|
||||
|
||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"})
|
||||
|
||||
|
||||
class LiteLLMProvider(LLMProvider):
|
||||
@ -119,23 +119,13 @@ class LiteLLMProvider(LLMProvider):
|
||||
@staticmethod
|
||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
sanitized = []
|
||||
system_contents: list[str] = []
|
||||
for message in messages:
|
||||
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
|
||||
if clean.get("role") == "system":
|
||||
content = clean.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
system_contents.append(content.strip())
|
||||
elif content is not None:
|
||||
system_contents.append(str(content))
|
||||
continue
|
||||
if clean.get("role") == "assistant" and "content" not in clean:
|
||||
clean["content"] = None
|
||||
if isinstance(clean.get("tool_calls"), list):
|
||||
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
|
||||
sanitized.append(clean)
|
||||
if system_contents:
|
||||
sanitized.insert(0, {"role": "system", "content": "\n\n".join(system_contents)})
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
@ -185,11 +175,15 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs["provider"] = provider_payload
|
||||
|
||||
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
|
||||
if enabled is None:
|
||||
return
|
||||
model_key = f"{original_model} {resolved_model}".lower()
|
||||
if "qwen" not in model_key:
|
||||
return
|
||||
extra_body = dict(kwargs.get("extra_body") or {})
|
||||
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
|
||||
chat_template_kwargs["enable_thinking"] = False
|
||||
chat_template_kwargs["enable_thinking"] = bool(enabled)
|
||||
extra_body["chat_template_kwargs"] = chat_template_kwargs
|
||||
extra_body["thinking"] = {"type": "disabled"}
|
||||
kwargs["extra_body"] = extra_body
|
||||
|
||||
async def chat(
|
||||
@ -197,7 +191,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -210,11 +204,10 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": resolved_model,
|
||||
"messages": sanitized_messages,
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
"timeout": self.request_timeout_seconds or 45.0,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max(1, max_tokens)
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.api_base:
|
||||
|
||||
@ -84,10 +84,8 @@ class MessageRecord:
|
||||
payload["task_id"] = self.event_payload.get("task_id")
|
||||
if self.event_payload.get("task_status"):
|
||||
payload["task_status"] = self.event_payload.get("task_status")
|
||||
if self.event_payload.get("evidence_status"):
|
||||
payload["evidence_status"] = self.event_payload.get("evidence_status")
|
||||
if self.event_payload.get("acceptance_state"):
|
||||
payload["acceptance_state"] = self.event_payload.get("acceptance_state")
|
||||
if self.event_payload.get("validation_status"):
|
||||
payload["validation_status"] = self.event_payload.get("validation_status")
|
||||
if self.event_payload.get("feedback_state"):
|
||||
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
||||
if self.event_payload.get("feedback_error"):
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@ -110,6 +111,12 @@ END;
|
||||
"""
|
||||
|
||||
|
||||
def _sqlite_journal_mode() -> str:
|
||||
requested = os.getenv("BEAVER_SQLITE_JOURNAL_MODE", "DELETE").strip().upper()
|
||||
allowed = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "OFF", "WAL"}
|
||||
return requested if requested in allowed else "DELETE"
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""SQLite-backed session store."""
|
||||
|
||||
@ -119,7 +126,9 @@ class SessionStore:
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False, isolation_level=None)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA mmap_size=0")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.execute(f"PRAGMA journal_mode={_sqlite_journal_mode()}")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
self._init_schema()
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ from .schema import (
|
||||
)
|
||||
|
||||
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
|
||||
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"},
|
||||
"local_filesystem_mcp": {"category": "filesystem", "display_name": "个人智能体文件系统工具"},
|
||||
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
|
||||
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
|
||||
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
|
||||
@ -86,25 +86,6 @@ def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
|
||||
model=_string(defaults.get("model") or data.get("model")),
|
||||
provider=_string(defaults.get("provider") or data.get("provider")),
|
||||
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
||||
max_tokens=_int(_first_config_value(
|
||||
defaults.get("maxTokens"),
|
||||
defaults.get("max_tokens"),
|
||||
data.get("maxTokens"),
|
||||
data.get("max_tokens"),
|
||||
)),
|
||||
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
|
||||
max_context_messages=_int(
|
||||
defaults.get("maxContextMessages")
|
||||
or defaults.get("max_context_messages")
|
||||
or data.get("maxContextMessages")
|
||||
or data.get("max_context_messages")
|
||||
),
|
||||
max_tool_iterations=_int(_first_config_value(
|
||||
defaults.get("maxToolIterations"),
|
||||
defaults.get("max_tool_iterations"),
|
||||
data.get("maxToolIterations"),
|
||||
data.get("max_tool_iterations"),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@ -211,13 +192,6 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_config_value(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
@ -243,13 +217,6 @@ def _float(value: Any) -> float | None:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _int(value: Any) -> int | None:
|
||||
parsed = _float(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
return int(parsed)
|
||||
|
||||
|
||||
def _bool(value: Any, *, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
@ -25,10 +25,6 @@ class AgentDefaultsConfig:
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
embedding_model: str | None = None
|
||||
max_tokens: int | None = None
|
||||
temperature: float | None = None
|
||||
max_context_messages: int | None = None
|
||||
max_tool_iterations: int | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@ -109,3 +109,15 @@ class AuthzClient:
|
||||
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
|
||||
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def get_minio_settings(self, backend_id: str) -> dict[str, Any]:
|
||||
data = await self._request("GET", f"/backends/{backend_id}/settings/minio")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def set_minio_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = await self._request("POST", f"/backends/{backend_id}/settings/minio", json_body=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def delete_minio_settings(self, backend_id: str) -> dict[str, Any]:
|
||||
data = await self._request("DELETE", f"/backends/{backend_id}/settings/minio")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
@ -27,12 +27,8 @@ from beaver.tools.builtins import (
|
||||
CronTool,
|
||||
DelegateTool,
|
||||
ExecuteCodeTool,
|
||||
ListDirectoryTool,
|
||||
MemoryTool,
|
||||
PatchFileTool,
|
||||
ProcessTool,
|
||||
ReadFileTool,
|
||||
SearchFilesTool,
|
||||
SendMessageTool,
|
||||
SkillManageTool,
|
||||
SkillViewTool,
|
||||
@ -40,6 +36,12 @@ from beaver.tools.builtins import (
|
||||
SpawnTool,
|
||||
TerminalTool,
|
||||
TodoTool,
|
||||
UserFilesCopyToWorkspaceTool,
|
||||
UserFilesListTool,
|
||||
UserFilesMkdirTool,
|
||||
UserFilesPublishOutputTool,
|
||||
UserFilesReadTool,
|
||||
UserFilesWriteTool,
|
||||
WebFetchTool,
|
||||
WebSearchTool,
|
||||
WriteFileTool,
|
||||
@ -47,7 +49,7 @@ from beaver.tools.builtins import (
|
||||
|
||||
|
||||
LOCAL_TOOL_CATEGORIES = {
|
||||
"filesystem": "Beaver Local Filesystem Tools",
|
||||
"filesystem": "Beaver Personal Agent Filesystem Tools",
|
||||
"runtime": "Beaver Local Runtime Tools",
|
||||
"memory": "Beaver Local Memory Tools",
|
||||
"skills": "Beaver Local Skills Tools",
|
||||
@ -84,11 +86,12 @@ def _category_tools(category: str, workspace: Path) -> tuple[list[BaseTool], Too
|
||||
|
||||
if category == "filesystem":
|
||||
tools: list[BaseTool] = [
|
||||
ObjectBackedTool(ListDirectoryTool()),
|
||||
ObjectBackedTool(ReadFileTool()),
|
||||
ObjectBackedTool(SearchFilesTool()),
|
||||
ObjectBackedTool(WriteFileTool()),
|
||||
ObjectBackedTool(PatchFileTool()),
|
||||
ObjectBackedTool(UserFilesListTool()),
|
||||
ObjectBackedTool(UserFilesReadTool()),
|
||||
ObjectBackedTool(UserFilesWriteTool()),
|
||||
ObjectBackedTool(UserFilesMkdirTool()),
|
||||
ObjectBackedTool(UserFilesCopyToWorkspaceTool()),
|
||||
ObjectBackedTool(UserFilesPublishOutputTool()),
|
||||
]
|
||||
elif category == "runtime":
|
||||
tools = [
|
||||
|
||||
@ -24,6 +24,19 @@ from beaver.integrations.mcp import MCPConnectionManager
|
||||
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
|
||||
from beaver.services.cron_service import CronService, schedule_from_api
|
||||
from beaver.services.skillhub_service import SkillHubService
|
||||
from beaver.services.user_files import (
|
||||
USER_FILE_ROOTS,
|
||||
UserFileError,
|
||||
UserFileNotFoundError,
|
||||
UserFilePathError,
|
||||
UserFileSizeError,
|
||||
UserFileService,
|
||||
)
|
||||
from beaver.services.user_file_resolver import (
|
||||
UserFileConfigurationError,
|
||||
UserFileStorageResolver,
|
||||
build_file_auth_context,
|
||||
)
|
||||
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
@ -44,15 +57,11 @@ from .files import (
|
||||
workspace_file_path,
|
||||
)
|
||||
from .schemas import (
|
||||
WebChatAcceptanceRequest,
|
||||
WebChatAcceptanceResponse,
|
||||
WebChatFeedbackRequest,
|
||||
WebChatFeedbackResponse,
|
||||
WebChatRequest,
|
||||
WebChatResponse,
|
||||
WebErrorResponse,
|
||||
WebAgentConfigRequest,
|
||||
WebAgentConfigResponse,
|
||||
WebProviderConfigRequest,
|
||||
WebProviderConfigResponse,
|
||||
WebStatusResponse,
|
||||
@ -159,13 +168,6 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
||||
return decorator
|
||||
|
||||
|
||||
RAW_TOOL_CALL_DISPLAY_FALLBACK = (
|
||||
"The run reached the configured tool-call limit before producing a reliable final answer. "
|
||||
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
|
||||
"Please request a revision to continue the task."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _app_lifespan(
|
||||
app: FastAPI,
|
||||
@ -317,6 +319,28 @@ def create_app(
|
||||
app.state.handoff_codes = {}
|
||||
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
|
||||
max_file_size = 50 * 1024 * 1024
|
||||
max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024)
|
||||
user_file_upload_part_size = _int_env("BEAVER_USER_FILES_UPLOAD_PART_SIZE", 10 * 1024 * 1024)
|
||||
|
||||
def _user_file_resolver(request: Request, authorization: str | None) -> UserFileStorageResolver:
|
||||
username = _require_web_user(app, authorization)
|
||||
loaded = get_agent_service(request).create_loop().boot()
|
||||
auth_context = build_file_auth_context(username=username, config=loaded.config)
|
||||
return UserFileStorageResolver(config=loaded.config, workspace=loaded.workspace, auth_context=auth_context)
|
||||
|
||||
async def _user_file_service(request: Request, authorization: str | None) -> UserFileService:
|
||||
return await _user_file_resolver(request, authorization).service()
|
||||
|
||||
def _user_file_http_error(exc: UserFileError) -> HTTPException:
|
||||
if isinstance(exc, UserFileNotFoundError):
|
||||
return HTTPException(status_code=404, detail=str(exc) or "File not found")
|
||||
if isinstance(exc, UserFilePathError):
|
||||
return HTTPException(status_code=400, detail=str(exc) or "Invalid path")
|
||||
if isinstance(exc, UserFileSizeError):
|
||||
return HTTPException(status_code=413, detail=str(exc) or "File too large")
|
||||
if isinstance(exc, UserFileConfigurationError):
|
||||
return HTTPException(status_code=503, detail=str(exc) or "User file storage is not configured")
|
||||
return HTTPException(status_code=400, detail=str(exc) or "User file operation failed")
|
||||
|
||||
@app.get("/api/ping", response_model=WebStatusResponse)
|
||||
async def ping(request: Request) -> WebStatusResponse:
|
||||
@ -376,7 +400,6 @@ def create_app(
|
||||
"workspace_exists": loaded.workspace.exists(),
|
||||
"model": config.default_model or agent_service.profile.default_model,
|
||||
"max_tokens": agent_service.profile.max_tokens,
|
||||
"max_context_messages": agent_service.profile.max_context_messages,
|
||||
"temperature": agent_service.profile.temperature,
|
||||
"max_tool_iterations": agent_service.profile.max_tool_iterations,
|
||||
"providers": providers_status,
|
||||
@ -597,38 +620,6 @@ def create_app(
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
|
||||
|
||||
@app.post("/api/agent-config", response_model=WebAgentConfigResponse)
|
||||
async def update_agent_config(
|
||||
request: Request,
|
||||
payload: WebAgentConfigRequest,
|
||||
) -> WebAgentConfigResponse:
|
||||
if payload.max_tokens is not None and payload.max_tokens <= 0:
|
||||
raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null")
|
||||
if payload.temperature < 0 or payload.temperature > 2:
|
||||
raise HTTPException(status_code=400, detail="temperature must be between 0 and 2")
|
||||
if payload.max_tool_iterations < 0:
|
||||
raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater")
|
||||
|
||||
agent_service = get_agent_service(request)
|
||||
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||
raw = _read_config_json(config_path)
|
||||
agents = _ensure_dict(raw, "agents")
|
||||
defaults = _ensure_dict(agents, "defaults")
|
||||
|
||||
if payload.max_tokens is None:
|
||||
defaults.pop("maxTokens", None)
|
||||
defaults.pop("max_tokens", None)
|
||||
else:
|
||||
defaults["maxTokens"] = payload.max_tokens
|
||||
defaults.pop("max_tokens", None)
|
||||
defaults["temperature"] = payload.temperature
|
||||
defaults["maxToolIterations"] = payload.max_tool_iterations
|
||||
defaults.pop("max_tool_iterations", None)
|
||||
|
||||
_write_config_json(config_path, raw)
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebAgentConfigResponse(ok=True)
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions(request: Request) -> list[dict[str, Any]]:
|
||||
loaded = get_agent_service(request).create_loop().boot()
|
||||
@ -791,6 +782,101 @@ def create_app(
|
||||
return {"ok": True}
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
@app.get("/api/user-files/status")
|
||||
async def user_files_status(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
return (await _user_file_resolver(request, authorization).status()).to_dict()
|
||||
|
||||
@app.get("/api/user-files/browse")
|
||||
async def browse_user_files(
|
||||
request: Request,
|
||||
path: str = "",
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await (await _user_file_service(request, authorization)).browse(path)
|
||||
except UserFileError as exc:
|
||||
raise _user_file_http_error(exc) from exc
|
||||
|
||||
@app.get("/api/user-files/download")
|
||||
async def download_user_file(
|
||||
path: str,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> Response:
|
||||
try:
|
||||
content = await (await _user_file_service(request, authorization)).download(path)
|
||||
except UserFileError as exc:
|
||||
raise _user_file_http_error(exc) from exc
|
||||
disposition = "inline" if content.content_type.startswith("image/") else "attachment"
|
||||
return Response(
|
||||
content=content.content,
|
||||
media_type=content.content_type,
|
||||
headers={"Content-Disposition": content_disposition(disposition, content.name)},
|
||||
)
|
||||
|
||||
@app.get("/api/user-files/preview")
|
||||
async def preview_user_file(
|
||||
path: str,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await (await _user_file_service(request, authorization)).preview(path)
|
||||
except UserFileError as exc:
|
||||
raise _user_file_http_error(exc) from exc
|
||||
|
||||
@app.post("/api/user-files/upload")
|
||||
async def upload_user_file(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
path: str = Form("uploads"),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No filename provided")
|
||||
file_size = getattr(file, "size", None)
|
||||
if isinstance(file_size, int) and file_size > max_user_file_upload_size:
|
||||
raise HTTPException(status_code=413, detail=f"File too large (max {_human_upload_size(max_user_file_upload_size)})")
|
||||
try:
|
||||
return await (await _user_file_service(request, authorization)).upload_stream(
|
||||
path,
|
||||
file.filename,
|
||||
file.file,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
max_bytes=max_user_file_upload_size,
|
||||
part_size=user_file_upload_part_size,
|
||||
)
|
||||
except UserFileError as exc:
|
||||
raise _user_file_http_error(exc) from exc
|
||||
|
||||
@app.delete("/api/user-files/delete")
|
||||
async def delete_user_file(
|
||||
path: str,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, bool]:
|
||||
try:
|
||||
removed = await (await _user_file_service(request, authorization)).delete(path)
|
||||
except UserFileError as exc:
|
||||
raise _user_file_http_error(exc) from exc
|
||||
if removed:
|
||||
return {"ok": True}
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
|
||||
@app.post("/api/user-files/mkdir")
|
||||
async def create_user_file_directory(
|
||||
path: str,
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await (await _user_file_service(request, authorization)).mkdir(path)
|
||||
except UserFileError as exc:
|
||||
raise _user_file_http_error(exc) from exc
|
||||
|
||||
@app.get("/api/workspace/browse")
|
||||
async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]:
|
||||
loaded = get_agent_service(request).create_loop().boot()
|
||||
@ -1763,8 +1849,7 @@ def create_app(
|
||||
usage=result.usage,
|
||||
task_id=result.task_id,
|
||||
task_status=result.task_status,
|
||||
evidence_status="recorded" if result.task_id else None,
|
||||
validation_result=None,
|
||||
validation_result=result.validation_result,
|
||||
)
|
||||
|
||||
fallback_target = _model_dump(payload.fallback_target)
|
||||
@ -1790,7 +1875,7 @@ def create_app(
|
||||
}
|
||||
if payload.thinking_enabled is not None:
|
||||
direct_kwargs["thinking_enabled"] = payload.thinking_enabled
|
||||
result = await _run_web_direct(agent_service, message, **direct_kwargs)
|
||||
result = await agent_service.submit_direct(message, **direct_kwargs)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
@ -1814,8 +1899,7 @@ def create_app(
|
||||
usage=result.usage,
|
||||
task_id=result.task_id,
|
||||
task_status=result.task_status,
|
||||
evidence_status="recorded" if result.task_id else None,
|
||||
validation_result=None,
|
||||
validation_result=result.validation_result,
|
||||
)
|
||||
|
||||
@app.websocket("/ws/{session_id:path}")
|
||||
@ -1901,7 +1985,7 @@ def create_app(
|
||||
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
|
||||
if websocket_thinking_enabled is not None:
|
||||
direct_kwargs["thinking_enabled"] = websocket_thinking_enabled
|
||||
result = await _run_web_direct(agent_service, content, **direct_kwargs)
|
||||
result = await agent_service.submit_direct(content, **direct_kwargs)
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{
|
||||
@ -1928,30 +2012,6 @@ def create_app(
|
||||
}
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/chat/acceptance",
|
||||
response_model=WebChatAcceptanceResponse,
|
||||
responses={
|
||||
400: {"model": WebErrorResponse},
|
||||
404: {"model": WebErrorResponse},
|
||||
},
|
||||
)
|
||||
async def chat_acceptance(request: Request, payload: WebChatAcceptanceRequest) -> WebChatAcceptanceResponse:
|
||||
agent_service = get_agent_service(request)
|
||||
try:
|
||||
result = await agent_service.submit_acceptance(
|
||||
session_id=payload.session_id,
|
||||
run_id=payload.run_id,
|
||||
acceptance_type=payload.acceptance_type,
|
||||
comment=payload.comment,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
status_code = 404 if "No internal task" in detail else 400
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
return WebChatAcceptanceResponse(**result)
|
||||
|
||||
@app.post(
|
||||
"/api/chat/feedback",
|
||||
response_model=WebChatFeedbackResponse,
|
||||
@ -1963,10 +2023,10 @@ def create_app(
|
||||
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
|
||||
agent_service = get_agent_service(request)
|
||||
try:
|
||||
result = await agent_service.submit_acceptance(
|
||||
result = await agent_service.submit_feedback(
|
||||
session_id=payload.session_id,
|
||||
run_id=payload.run_id,
|
||||
acceptance_type=payload.feedback_type,
|
||||
feedback_type=payload.feedback_type,
|
||||
comment=payload.comment,
|
||||
)
|
||||
except ValueError as exc:
|
||||
@ -1985,21 +2045,15 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An
|
||||
role = event.get("role")
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
content = event.get("content") or ""
|
||||
comparable_content = str(content).replace("\u200b", "").replace("\u200c", "").replace("\u200d", "").replace("\ufeff", "")
|
||||
if role == "assistant" and not comparable_content.strip():
|
||||
continue
|
||||
content = _sanitize_user_visible_assistant_content(role=role, content=content)
|
||||
messages.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
"content": event.get("content") or "",
|
||||
"timestamp": _iso_from_timestamp(event.get("timestamp")),
|
||||
"run_id": event.get("run_id"),
|
||||
"task_id": event.get("task_id"),
|
||||
"task_status": event.get("task_status"),
|
||||
"evidence_status": event.get("evidence_status"),
|
||||
"acceptance_state": event.get("acceptance_state"),
|
||||
"validation_status": event.get("validation_status"),
|
||||
"feedback_state": event.get("feedback_state"),
|
||||
"feedback_error": event.get("feedback_error"),
|
||||
"message_type": event.get("message_type"),
|
||||
@ -2016,12 +2070,6 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An
|
||||
}
|
||||
|
||||
|
||||
async def _run_web_direct(agent_service: AgentService, message: str, **kwargs: Any) -> Any:
|
||||
if agent_service.is_running:
|
||||
return await agent_service.submit_direct(message, **kwargs)
|
||||
return await agent_service.process_direct(message, **kwargs)
|
||||
|
||||
|
||||
def _create_skill_upload_draft(loaded: Any, filename: str, content: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(content), "r")
|
||||
@ -2218,7 +2266,6 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
|
||||
content = (record.content or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
content = _sanitize_user_visible_assistant_content(role=record.role, content=content)
|
||||
messages.append(
|
||||
{
|
||||
"role": record.role,
|
||||
@ -2227,6 +2274,7 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
|
||||
"tool_name": record.tool_name,
|
||||
}
|
||||
)
|
||||
validation = run_record.validation_result if run_record is not None else None
|
||||
views.append(
|
||||
{
|
||||
"run_id": run_id,
|
||||
@ -2239,8 +2287,7 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
|
||||
"attempt_index": run_record.attempt_index if run_record is not None else None,
|
||||
"task_text": run_record.task_text if run_record is not None else "",
|
||||
"messages": messages,
|
||||
"evidence_status": "recorded",
|
||||
"validation_result": None,
|
||||
"validation_result": validation,
|
||||
}
|
||||
)
|
||||
return views
|
||||
@ -2505,6 +2552,12 @@ def _model_dump(value: Any) -> dict[str, Any] | None:
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _validation_status(validation_result: dict[str, Any] | None) -> str:
|
||||
if validation_result is None:
|
||||
return "unknown"
|
||||
return "passed" if validation_result.get("accepted") is True else "failed"
|
||||
|
||||
|
||||
def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
result: dict[str, Any] = dict(metadata)
|
||||
@ -2538,15 +2591,13 @@ def _int_or_none(value: Any) -> int | None:
|
||||
|
||||
|
||||
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
validation_result = getattr(result, "validation_result", None)
|
||||
task_id = getattr(result, "task_id", None)
|
||||
task_status = getattr(result, "task_status", None)
|
||||
return {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": _sanitize_user_visible_assistant_content(
|
||||
role="assistant",
|
||||
content=getattr(result, "output_text", "") or "",
|
||||
),
|
||||
"content": getattr(result, "output_text", "") or "",
|
||||
"session_id": getattr(result, "session_id", None),
|
||||
"run_id": getattr(result, "run_id", None),
|
||||
"finish_reason": getattr(result, "finish_reason", None),
|
||||
@ -2556,39 +2607,17 @@ def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) ->
|
||||
"usage": dict(getattr(result, "usage", {}) or {}),
|
||||
"task_id": task_id,
|
||||
"task_status": task_status,
|
||||
"evidence_status": "recorded" if task_id else None,
|
||||
"validation_result": None,
|
||||
"validation_result": validation_result,
|
||||
"validation_status": _validation_status(validation_result),
|
||||
"metadata": {
|
||||
"task_id": task_id,
|
||||
"task_status": task_status,
|
||||
"evidence_status": "recorded" if task_id else None,
|
||||
"validation_result": validation_result,
|
||||
"input_metadata": _websocket_input_metadata(input_payload),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_user_visible_assistant_content(*, role: str, content: str) -> str:
|
||||
if role != "assistant":
|
||||
return content
|
||||
if _looks_like_raw_tool_call(content):
|
||||
return RAW_TOOL_CALL_DISPLAY_FALLBACK
|
||||
return content
|
||||
|
||||
|
||||
def _looks_like_raw_tool_call(content: str | None) -> bool:
|
||||
if not content:
|
||||
return False
|
||||
stripped = content.strip()
|
||||
lowered = stripped.lower()
|
||||
return (
|
||||
lowered.startswith("<tool_call")
|
||||
and lowered.endswith("</tool_call>")
|
||||
) or (
|
||||
lowered.startswith("<function=")
|
||||
and lowered.endswith("</function>")
|
||||
)
|
||||
|
||||
|
||||
def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
|
||||
if provider_cfg is None or provider_name == "custom":
|
||||
return False
|
||||
@ -2677,6 +2706,27 @@ def _handoff_replay_window_seconds() -> int:
|
||||
return 15
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _human_upload_size(size: int) -> str:
|
||||
units = ("B", "KB", "MB", "GB", "TB")
|
||||
value = float(size)
|
||||
for unit in units:
|
||||
if value < 1024 or unit == units[-1]:
|
||||
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
|
||||
value /= 1024
|
||||
return f"{size}B"
|
||||
|
||||
|
||||
def _prune_handoff_codes(app: FastAPI) -> None:
|
||||
now = time.time()
|
||||
replay_window = _handoff_replay_window_seconds()
|
||||
@ -3075,7 +3125,6 @@ def _write_config_json(path: Path, data: dict[str, Any]) -> None:
|
||||
def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
|
||||
config = load_config(config_path=config_path)
|
||||
agent_service.loader.config = config
|
||||
agent_service._apply_configured_profile_defaults() # noqa: SLF001
|
||||
loop = getattr(agent_service, "_loop", None)
|
||||
loaded = getattr(loop, "loaded", None) if loop is not None else None
|
||||
if loaded is not None:
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
"""Web request and response schemas."""
|
||||
|
||||
from .chat import (
|
||||
WebChatAcceptanceRequest,
|
||||
WebChatAcceptanceResponse,
|
||||
WebChatFeedbackRequest,
|
||||
WebChatFeedbackResponse,
|
||||
WebChatRequest,
|
||||
WebChatResponse,
|
||||
WebErrorResponse,
|
||||
WebAgentConfigRequest,
|
||||
WebAgentConfigResponse,
|
||||
WebProviderConfigRequest,
|
||||
WebProviderConfigResponse,
|
||||
WebProviderTarget,
|
||||
@ -17,15 +13,11 @@ from .chat import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WebChatAcceptanceRequest",
|
||||
"WebChatAcceptanceResponse",
|
||||
"WebChatFeedbackRequest",
|
||||
"WebChatFeedbackResponse",
|
||||
"WebChatRequest",
|
||||
"WebChatResponse",
|
||||
"WebErrorResponse",
|
||||
"WebAgentConfigRequest",
|
||||
"WebAgentConfigResponse",
|
||||
"WebProviderConfigRequest",
|
||||
"WebProviderConfigResponse",
|
||||
"WebProviderTarget",
|
||||
|
||||
@ -82,45 +82,29 @@ class WebChatResponse(BaseModel):
|
||||
usage: dict[str, Any] = Field(default_factory=dict)
|
||||
task_id: str | None = None
|
||||
task_status: str | None = None
|
||||
evidence_status: str | None = None
|
||||
acceptance_state: str | None = None
|
||||
validation_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WebChatAcceptanceRequest(BaseModel):
|
||||
"""User acceptance on the latest assistant result in chat."""
|
||||
class WebChatFeedbackRequest(BaseModel):
|
||||
"""Feedback on the latest assistant result in chat."""
|
||||
|
||||
session_id: str
|
||||
run_id: str
|
||||
acceptance_type: str
|
||||
feedback_type: str
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class WebChatAcceptanceResponse(BaseModel):
|
||||
"""Acceptance recording result."""
|
||||
class WebChatFeedbackResponse(BaseModel):
|
||||
"""Feedback recording result."""
|
||||
|
||||
session_id: str
|
||||
run_id: str
|
||||
task_id: str
|
||||
task_status: str
|
||||
acceptance_type: str
|
||||
feedback_type: str
|
||||
learning_candidates: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebChatFeedbackRequest(BaseModel):
|
||||
"""Backward-compatible feedback payload."""
|
||||
|
||||
session_id: str
|
||||
run_id: str
|
||||
feedback_type: str
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class WebChatFeedbackResponse(WebChatAcceptanceResponse):
|
||||
"""Backward-compatible feedback response."""
|
||||
|
||||
|
||||
class WebProviderConfigRequest(BaseModel):
|
||||
"""Provider config update from the status page."""
|
||||
|
||||
@ -139,20 +123,6 @@ class WebProviderConfigResponse(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class WebAgentConfigRequest(BaseModel):
|
||||
"""Agent runtime defaults update from the settings page."""
|
||||
|
||||
max_tokens: int | None = None
|
||||
temperature: float
|
||||
max_tool_iterations: int
|
||||
|
||||
|
||||
class WebAgentConfigResponse(BaseModel):
|
||||
"""Agent runtime defaults update result."""
|
||||
|
||||
ok: bool
|
||||
|
||||
|
||||
class WebStatusResponse(BaseModel):
|
||||
"""Web 宿主层状态响应。"""
|
||||
|
||||
|
||||
@ -22,16 +22,7 @@ from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
from beaver.foundation.models import CronJob, CronRunRecord
|
||||
from beaver.tasks import (
|
||||
EvidenceBuilder,
|
||||
MainAgentRouter,
|
||||
RunEvidence,
|
||||
TaskEvidencePacket,
|
||||
TaskExecutionPlan,
|
||||
TaskRecord,
|
||||
render_task_evidence,
|
||||
)
|
||||
from beaver.tasks.service import normalize_acceptance_type
|
||||
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
|
||||
|
||||
|
||||
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
|
||||
@ -60,27 +51,11 @@ class AgentService:
|
||||
) -> None:
|
||||
self.profile = profile or AgentProfile()
|
||||
self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
|
||||
self._apply_configured_profile_defaults()
|
||||
self._loop: AgentLoop | None = None
|
||||
self._run_task: asyncio.Task[None] | None = None
|
||||
self._main_agent_router = MainAgentRouter()
|
||||
self._runtime_services: dict[str, Any] = {}
|
||||
|
||||
def _apply_configured_profile_defaults(self) -> None:
|
||||
defaults = self.loader.config.agents_defaults
|
||||
self.profile.max_tokens = None
|
||||
self.profile.temperature = 0.2
|
||||
self.profile.max_context_messages = 1000
|
||||
self.profile.max_tool_iterations = 30
|
||||
if defaults.max_tokens is not None:
|
||||
self.profile.max_tokens = max(1, defaults.max_tokens)
|
||||
if defaults.temperature is not None:
|
||||
self.profile.temperature = defaults.temperature
|
||||
if defaults.max_context_messages is not None:
|
||||
self.profile.max_context_messages = max(1, defaults.max_context_messages)
|
||||
if defaults.max_tool_iterations is not None:
|
||||
self.profile.max_tool_iterations = max(0, defaults.max_tool_iterations)
|
||||
|
||||
def create_loop(self) -> AgentLoop:
|
||||
"""创建并缓存当前 service 使用的 AgentLoop。"""
|
||||
|
||||
@ -248,7 +223,7 @@ class AgentService:
|
||||
|
||||
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
|
||||
This entry bypasses the main-agent classifier and forces Task mode so
|
||||
every trigger produces a TaskRecord, evidence, acceptance state, and a
|
||||
every trigger produces a TaskRecord, validation, feedback state, and a
|
||||
run_id that the scheduled-task history can link to.
|
||||
"""
|
||||
|
||||
@ -296,9 +271,9 @@ class AgentService:
|
||||
result.run_id,
|
||||
{
|
||||
"message_type": "scheduled_reply",
|
||||
"scheduled_job_id": cron_job_id,
|
||||
"scheduled_run_id": scheduled_run_id,
|
||||
"cron_job_name": cron_job_name,
|
||||
"scheduled_job_id": job.id,
|
||||
"scheduled_run_id": run.scheduled_run_id,
|
||||
"cron_job_name": job.name,
|
||||
"mode": "notification",
|
||||
},
|
||||
)
|
||||
@ -419,15 +394,15 @@ class AgentService:
|
||||
},
|
||||
)
|
||||
|
||||
async def submit_acceptance(
|
||||
async def submit_feedback(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
acceptance_type: str,
|
||||
feedback_type: str,
|
||||
comment: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record user acceptance for the internal task linked to a run."""
|
||||
"""Record chat feedback for the internal task linked to a run."""
|
||||
|
||||
loaded = self.create_loop().boot()
|
||||
task_service = self._require_loaded(loaded, "task_service")
|
||||
@ -435,31 +410,32 @@ class AgentService:
|
||||
if task is None or task.session_id != session_id:
|
||||
raise ValueError(f"No internal task found for run_id={run_id!r}")
|
||||
|
||||
normalized = normalize_acceptance_type(acceptance_type)
|
||||
legacy_feedback_type = "satisfied" if normalized == "accept" else normalized
|
||||
normalized = feedback_type.strip().lower()
|
||||
if normalized not in {"satisfied", "revise", "abandon"}:
|
||||
raise ValueError("feedback_type must be one of: satisfied, revise, abandon")
|
||||
|
||||
already_recorded = any(
|
||||
item.get("run_id") == run_id and item.get("acceptance_type") == normalized
|
||||
item.get("run_id") == run_id and item.get("feedback_type") == normalized
|
||||
for item in task.feedback
|
||||
)
|
||||
conflicting_acceptance = next(
|
||||
conflicting_feedback = next(
|
||||
(
|
||||
item
|
||||
for item in task.feedback
|
||||
if item.get("run_id") == run_id and item.get("acceptance_type") != normalized
|
||||
if item.get("run_id") == run_id and item.get("feedback_type") != normalized
|
||||
),
|
||||
None,
|
||||
)
|
||||
if conflicting_acceptance is not None:
|
||||
if conflicting_feedback is not None:
|
||||
raise ValueError(
|
||||
f"Acceptance for run_id={run_id!r} was already recorded as "
|
||||
f"{conflicting_acceptance.get('acceptance_type')!r}"
|
||||
f"Feedback for run_id={run_id!r} was already recorded as "
|
||||
f"{conflicting_feedback.get('feedback_type')!r}"
|
||||
)
|
||||
if task.status in {"closed", "abandoned"} and not already_recorded:
|
||||
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
|
||||
updated = task if already_recorded else task_service.add_acceptance(
|
||||
updated = task if already_recorded else task_service.add_feedback(
|
||||
task.task_id,
|
||||
acceptance_type=normalized,
|
||||
feedback_type=normalized,
|
||||
comment=comment,
|
||||
run_id=run_id,
|
||||
)
|
||||
@ -470,8 +446,7 @@ class AgentService:
|
||||
{
|
||||
"task_id": updated.task_id,
|
||||
"task_status": updated.status,
|
||||
"acceptance_state": normalized,
|
||||
"feedback_state": legacy_feedback_type,
|
||||
"feedback_state": normalized,
|
||||
},
|
||||
)
|
||||
if not already_recorded:
|
||||
@ -479,11 +454,10 @@ class AgentService:
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="system",
|
||||
event_type="task_acceptance_recorded",
|
||||
event_type="task_feedback_recorded",
|
||||
event_payload={
|
||||
"task_id": task.task_id,
|
||||
"acceptance_type": normalized,
|
||||
"feedback_type": legacy_feedback_type,
|
||||
"feedback_type": normalized,
|
||||
"comment": comment,
|
||||
"task_status": updated.status,
|
||||
},
|
||||
@ -492,36 +466,35 @@ class AgentService:
|
||||
)
|
||||
|
||||
generated_candidates = []
|
||||
validation = ValidationResult.from_dict(updated.validation_result)
|
||||
if not already_recorded:
|
||||
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||
acceptance_payload = {
|
||||
"acceptance_type": normalized,
|
||||
"feedback_type": legacy_feedback_type,
|
||||
feedback_payload = {
|
||||
"feedback_type": normalized,
|
||||
"comment": comment or "",
|
||||
"task_status": updated.status,
|
||||
"final_accepted_run_id": updated.metadata.get("final_accepted_run_id"),
|
||||
}
|
||||
run_memory_store.update_run_record(
|
||||
run_id,
|
||||
success=normalized == "accept",
|
||||
feedback=acceptance_payload,
|
||||
success=normalized == "satisfied",
|
||||
feedback=feedback_payload,
|
||||
)
|
||||
run_memory_store.update_skill_effects_for_run(
|
||||
run_id,
|
||||
success=normalized == "accept",
|
||||
feedback_score=self._acceptance_score_for_learning(normalized),
|
||||
success=normalized == "satisfied",
|
||||
feedback_score=self._feedback_score_for_learning(normalized, validation),
|
||||
notes=(comment or normalized).strip(),
|
||||
)
|
||||
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
||||
skill_learning_service.rescore_skill_versions()
|
||||
if already_recorded:
|
||||
generated_candidates = []
|
||||
elif normalized == "accept":
|
||||
elif normalized == "satisfied" and validation is not None and validation.accepted:
|
||||
generated_candidates = [
|
||||
item.to_dict()
|
||||
for item in skill_learning_service.build_learning_candidates_for_task(
|
||||
updated.task_id,
|
||||
final_accepted_run_id=run_id,
|
||||
trigger_run_id=run_id,
|
||||
)
|
||||
]
|
||||
elif normalized == "abandon":
|
||||
@ -532,8 +505,7 @@ class AgentService:
|
||||
event_type="task_failure_evidence_recorded",
|
||||
event_payload={
|
||||
"task_id": updated.task_id,
|
||||
"acceptance_type": normalized,
|
||||
"feedback_type": legacy_feedback_type,
|
||||
"feedback_type": normalized,
|
||||
"comment": comment or "",
|
||||
"task_status": updated.status,
|
||||
"durable_memory_written": False,
|
||||
@ -547,28 +519,10 @@ class AgentService:
|
||||
"run_id": run_id,
|
||||
"task_id": updated.task_id,
|
||||
"task_status": updated.status,
|
||||
"acceptance_type": normalized,
|
||||
"feedback_type": legacy_feedback_type,
|
||||
"feedback_type": normalized,
|
||||
"learning_candidates": generated_candidates,
|
||||
}
|
||||
|
||||
async def submit_feedback(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
feedback_type: str,
|
||||
comment: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Backward-compatible wrapper for older clients."""
|
||||
|
||||
return await self.submit_acceptance(
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
acceptance_type=feedback_type,
|
||||
comment=comment,
|
||||
)
|
||||
|
||||
async def _process_with_main_agent(
|
||||
self,
|
||||
message: str,
|
||||
@ -628,7 +582,7 @@ class AgentService:
|
||||
else active_task
|
||||
)
|
||||
if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id:
|
||||
task = self._record_revision_acceptance_for_task(
|
||||
task = self._record_revision_feedback_for_task(
|
||||
loaded,
|
||||
task=task,
|
||||
session_id=session_id,
|
||||
@ -636,7 +590,7 @@ class AgentService:
|
||||
)
|
||||
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
|
||||
|
||||
def _record_revision_acceptance_for_task(
|
||||
def _record_revision_feedback_for_task(
|
||||
self,
|
||||
loaded: Any,
|
||||
*,
|
||||
@ -644,9 +598,9 @@ class AgentService:
|
||||
session_id: str,
|
||||
comment: str,
|
||||
) -> TaskRecord:
|
||||
"""Mark the latest acceptance-eligible run as revised before continuing a task."""
|
||||
"""Mark the latest feedback-eligible run as revised before continuing a task."""
|
||||
|
||||
if task.status not in {"awaiting_acceptance", "needs_revision"}:
|
||||
if task.status not in {"awaiting_feedback", "needs_revision"}:
|
||||
return task
|
||||
run_id = next((item for item in reversed(task.run_ids) if item), None)
|
||||
if not run_id:
|
||||
@ -654,15 +608,15 @@ class AgentService:
|
||||
|
||||
existing = next((item for item in task.feedback if item.get("run_id") == run_id), None)
|
||||
if existing is not None:
|
||||
if existing.get("acceptance_type") != "revise":
|
||||
if existing.get("feedback_type") != "revise":
|
||||
return task
|
||||
updated = task
|
||||
already_recorded = True
|
||||
else:
|
||||
task_service = self._require_loaded(loaded, "task_service")
|
||||
updated = task_service.add_acceptance(
|
||||
updated = task_service.add_feedback(
|
||||
task.task_id,
|
||||
acceptance_type="revise",
|
||||
feedback_type="revise",
|
||||
comment=comment,
|
||||
run_id=run_id,
|
||||
)
|
||||
@ -675,7 +629,6 @@ class AgentService:
|
||||
{
|
||||
"task_id": updated.task_id,
|
||||
"task_status": updated.status,
|
||||
"acceptance_state": "revise",
|
||||
"feedback_state": "revise",
|
||||
},
|
||||
)
|
||||
@ -686,10 +639,9 @@ class AgentService:
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="system",
|
||||
event_type="task_acceptance_recorded",
|
||||
event_type="task_feedback_recorded",
|
||||
event_payload={
|
||||
"task_id": updated.task_id,
|
||||
"acceptance_type": "revise",
|
||||
"feedback_type": "revise",
|
||||
"comment": comment,
|
||||
"task_status": updated.status,
|
||||
@ -698,12 +650,12 @@ class AgentService:
|
||||
content=comment,
|
||||
context_visible=False,
|
||||
)
|
||||
validation = ValidationResult.from_dict(updated.validation_result)
|
||||
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||
run_memory_store.update_run_record(
|
||||
run_id,
|
||||
success=False,
|
||||
feedback={
|
||||
"acceptance_type": "revise",
|
||||
"feedback_type": "revise",
|
||||
"comment": comment,
|
||||
"task_status": updated.status,
|
||||
@ -712,7 +664,7 @@ class AgentService:
|
||||
run_memory_store.update_skill_effects_for_run(
|
||||
run_id,
|
||||
success=False,
|
||||
feedback_score=self._acceptance_score_for_learning("revise"),
|
||||
feedback_score=self._feedback_score_for_learning("revise", validation),
|
||||
notes=comment.strip() or "revise",
|
||||
)
|
||||
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
||||
@ -729,185 +681,181 @@ class AgentService:
|
||||
) -> AgentRunResult:
|
||||
loaded = self.create_loop().boot()
|
||||
task_service = self._require_loaded(loaded, "task_service")
|
||||
validation_service = self._require_loaded(loaded, "validation_service")
|
||||
task_execution_planner = self._require_loaded(loaded, "task_execution_planner")
|
||||
session_manager = self._require_loaded(loaded, "session_manager")
|
||||
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||
|
||||
last_result: AgentRunResult | None = None
|
||||
latest_validation: ValidationResult | None = None
|
||||
base_execution_context = kwargs.get("execution_context")
|
||||
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
|
||||
kwargs = dict(kwargs)
|
||||
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
|
||||
kwargs["provider_bundle"] = provider_bundle
|
||||
|
||||
attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1
|
||||
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
|
||||
plan = await task_execution_planner.plan(
|
||||
task=task,
|
||||
user_message=message,
|
||||
attempt_index=attempt_index,
|
||||
provider_bundle=provider_bundle,
|
||||
)
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_execution_planned",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
**plan.to_event_payload(),
|
||||
},
|
||||
)
|
||||
team_summaries: list[str] = []
|
||||
team_execution_context = ""
|
||||
team_result: TeamRunResult | None = None
|
||||
if plan.is_team:
|
||||
team_result, team_error = await self._run_team_for_task(
|
||||
plan,
|
||||
for attempt_index in (1, 2):
|
||||
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
|
||||
plan = await task_execution_planner.plan(
|
||||
task=task,
|
||||
parent_session_id=kwargs["session_id"],
|
||||
provider_bundle_factory=team_provider_bundle_factory
|
||||
or self._build_team_provider_bundle_factory(loaded, kwargs),
|
||||
user_message=message,
|
||||
attempt_index=attempt_index,
|
||||
latest_validation=latest_validation,
|
||||
provider_bundle=provider_bundle,
|
||||
)
|
||||
if team_result is not None:
|
||||
team_summaries = [self._team_summary_for_validation(team_result)]
|
||||
team_packet = TaskEvidencePacket(
|
||||
task_id=task.task_id,
|
||||
attempt_index=attempt_index,
|
||||
main_run=None,
|
||||
team_runs=self._team_run_evidence(team_result),
|
||||
team_node_results=list(team_result.node_results),
|
||||
final_output="",
|
||||
)
|
||||
team_execution_context = self._join_context(
|
||||
self._team_execution_context(plan, team_result),
|
||||
"Rendered team evidence:\n" + render_task_evidence(team_packet),
|
||||
)
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"plan_mode": plan.mode,
|
||||
"strategy": plan.graph.strategy if plan.graph else None,
|
||||
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
|
||||
"team_run_ids": team_result.run_ids,
|
||||
"team_success": team_result.success,
|
||||
"node_results": self._team_node_results_for_event(plan, team_result),
|
||||
"reason": plan.reason,
|
||||
"error": None if team_result.success else "one or more team nodes failed",
|
||||
},
|
||||
)
|
||||
else:
|
||||
team_summaries = [f"Team execution failed: {team_error}"]
|
||||
team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_team_run_failed",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"plan_mode": plan.mode,
|
||||
"strategy": plan.graph.strategy if plan.graph else None,
|
||||
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
|
||||
"team_run_ids": [],
|
||||
"team_success": False,
|
||||
"reason": plan.reason,
|
||||
"error": team_error,
|
||||
},
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_execution_planned",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
**plan.to_event_payload(),
|
||||
},
|
||||
)
|
||||
team_summaries: list[str] = []
|
||||
team_execution_context = ""
|
||||
if plan.is_team:
|
||||
team_result, team_error = await self._run_team_for_task(
|
||||
plan,
|
||||
task=task,
|
||||
parent_session_id=kwargs["session_id"],
|
||||
provider_bundle_factory=team_provider_bundle_factory
|
||||
or self._build_team_provider_bundle_factory(loaded, kwargs),
|
||||
)
|
||||
if team_result is not None:
|
||||
team_summaries = [self._team_summary_for_validation(team_result)]
|
||||
team_execution_context = self._team_execution_context(plan, team_result)
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"plan_mode": plan.mode,
|
||||
"strategy": plan.graph.strategy if plan.graph else None,
|
||||
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
|
||||
"team_run_ids": team_result.run_ids,
|
||||
"team_success": team_result.success,
|
||||
"node_results": self._team_node_results_for_event(plan, team_result),
|
||||
"reason": plan.reason,
|
||||
"error": None if team_result.success else "one or more team nodes failed",
|
||||
},
|
||||
)
|
||||
else:
|
||||
team_summaries = [f"Team execution failed: {team_error}"]
|
||||
team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_team_run_failed",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"plan_mode": plan.mode,
|
||||
"strategy": plan.graph.strategy if plan.graph else None,
|
||||
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
|
||||
"team_run_ids": [],
|
||||
"team_success": False,
|
||||
"reason": plan.reason,
|
||||
"error": team_error,
|
||||
},
|
||||
)
|
||||
|
||||
attempt_kwargs = dict(kwargs)
|
||||
attempt_kwargs.update(
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"task_mode": True,
|
||||
"attempt_index": attempt_index,
|
||||
"allow_candidate_generation": False,
|
||||
}
|
||||
)
|
||||
if team_execution_context:
|
||||
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
|
||||
if plan.is_team and team_execution_context:
|
||||
attempt_kwargs["include_tools"] = False
|
||||
attempt_kwargs["max_tool_iterations"] = 0
|
||||
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
|
||||
task=task,
|
||||
user_message=message,
|
||||
attempt_index=attempt_index,
|
||||
plan=plan,
|
||||
team_summaries=team_summaries,
|
||||
)
|
||||
attempt_kwargs = dict(kwargs)
|
||||
attempt_kwargs.update(
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"task_mode": True,
|
||||
"attempt_index": attempt_index,
|
||||
"allow_candidate_generation": False,
|
||||
}
|
||||
)
|
||||
if attempt_index == 2 and latest_validation is not None:
|
||||
revision_context = latest_validation.recommended_revision_prompt.strip()
|
||||
if revision_context:
|
||||
attempt_kwargs["execution_context"] = self._join_context(
|
||||
base_execution_context,
|
||||
f"Task validation revision request:\n{revision_context}",
|
||||
team_execution_context,
|
||||
)
|
||||
elif team_execution_context:
|
||||
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
|
||||
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
|
||||
task=task,
|
||||
user_message=message,
|
||||
attempt_index=attempt_index,
|
||||
latest_validation=latest_validation,
|
||||
plan=plan,
|
||||
team_summaries=team_summaries,
|
||||
)
|
||||
|
||||
result = await runner(message, **attempt_kwargs)
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_synthesis_completed",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"main_run_id": result.run_id,
|
||||
"plan_mode": plan.mode,
|
||||
"strategy": plan.graph.strategy if plan.graph else None,
|
||||
},
|
||||
)
|
||||
task = task_service.append_run(
|
||||
task.task_id,
|
||||
result.run_id,
|
||||
skill_names=self._skill_names_for_run(loaded, result.run_id),
|
||||
)
|
||||
evidence_packet = self._build_task_evidence_packet(
|
||||
session_manager=session_manager,
|
||||
task=task,
|
||||
attempt_index=attempt_index,
|
||||
result=result,
|
||||
team_result=team_result,
|
||||
)
|
||||
evidence_text = render_task_evidence(evidence_packet)
|
||||
evidence_debug = {
|
||||
"evidence_run_ids": [
|
||||
item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
|
||||
],
|
||||
"evidence_session_ids": [
|
||||
item.session_id
|
||||
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
|
||||
if item is not None
|
||||
],
|
||||
"tool_result_count": sum(
|
||||
len(item.tool_results)
|
||||
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
|
||||
if item is not None
|
||||
),
|
||||
"evidence_length": len(evidence_text),
|
||||
}
|
||||
session_manager.update_latest_assistant_event_payload(
|
||||
result.session_id,
|
||||
result.run_id,
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"task_status": task.status,
|
||||
"evidence_status": "recorded",
|
||||
},
|
||||
)
|
||||
session_manager.append_message(
|
||||
result.session_id,
|
||||
run_id=result.run_id,
|
||||
role="system",
|
||||
event_type="task_evidence_recorded",
|
||||
event_payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"evidence_debug": evidence_debug,
|
||||
},
|
||||
content=None,
|
||||
context_visible=False,
|
||||
)
|
||||
result.task_id = task.task_id
|
||||
result.task_status = task.status
|
||||
result.validation_result = None
|
||||
return result
|
||||
result = await runner(message, **attempt_kwargs)
|
||||
last_result = result
|
||||
self._append_task_observation(
|
||||
session_manager,
|
||||
task.session_id,
|
||||
event_type="task_synthesis_completed",
|
||||
payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"main_run_id": result.run_id,
|
||||
"plan_mode": plan.mode,
|
||||
"strategy": plan.graph.strategy if plan.graph else None,
|
||||
},
|
||||
)
|
||||
task = task_service.append_run(
|
||||
task.task_id,
|
||||
result.run_id,
|
||||
skill_names=self._skill_names_for_run(loaded, result.run_id),
|
||||
)
|
||||
validation = await validation_service.validate_task_result(
|
||||
task=task,
|
||||
user_message=message,
|
||||
final_output=result.output_text,
|
||||
transcript_excerpt=self._run_excerpt(session_manager, result.session_id, result.run_id),
|
||||
tool_summaries=self._tool_summaries(session_manager, result.session_id, result.run_id),
|
||||
team_summaries=team_summaries,
|
||||
provider_bundle=provider_bundle,
|
||||
)
|
||||
latest_validation = validation
|
||||
task = task_service.record_validation(task.task_id, result.run_id, validation)
|
||||
run_memory_store.update_run_record(result.run_id, validation_result=validation.to_dict())
|
||||
session_manager.update_latest_assistant_event_payload(
|
||||
result.session_id,
|
||||
result.run_id,
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"task_status": task.status,
|
||||
"validation_status": "passed" if validation.accepted else "failed",
|
||||
},
|
||||
)
|
||||
session_manager.append_message(
|
||||
result.session_id,
|
||||
run_id=result.run_id,
|
||||
role="system",
|
||||
event_type="task_validation_snapshotted",
|
||||
event_payload={
|
||||
"task_id": task.task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"validation_result": validation.to_dict(),
|
||||
"retry_scheduled": not validation.accepted and attempt_index == 1,
|
||||
},
|
||||
content=validation.recommended_revision_prompt or None,
|
||||
context_visible=False,
|
||||
)
|
||||
if not validation.accepted and attempt_index == 1:
|
||||
session_manager.set_run_context_visible(result.session_id, result.run_id, False)
|
||||
result.task_id = task.task_id
|
||||
result.task_status = task.status
|
||||
result.validation_result = validation.to_dict()
|
||||
if validation.accepted or attempt_index == 2:
|
||||
return result
|
||||
|
||||
if last_result is None: # pragma: no cover - defensive
|
||||
raise RuntimeError("Task mode did not produce a run result")
|
||||
return last_result
|
||||
|
||||
async def _run_team_for_task(
|
||||
self,
|
||||
@ -974,10 +922,12 @@ class AgentService:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _acceptance_score_for_learning(acceptance_type: str) -> float:
|
||||
if acceptance_type == "accept":
|
||||
def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float:
|
||||
if feedback_type == "satisfied":
|
||||
if validation is not None:
|
||||
return max(0.0, min(1.0, float(validation.score)))
|
||||
return 1.0
|
||||
if acceptance_type == "revise":
|
||||
if feedback_type == "revise":
|
||||
return 0.5
|
||||
return 0.0
|
||||
|
||||
@ -987,11 +937,12 @@ class AgentService:
|
||||
task: TaskRecord,
|
||||
user_message: str,
|
||||
attempt_index: int,
|
||||
latest_validation: ValidationResult | None = None,
|
||||
plan: TaskExecutionPlan | None = None,
|
||||
team_summaries: list[str] | None = None,
|
||||
) -> str:
|
||||
phase = f"attempt_{attempt_index}"
|
||||
if task.feedback and task.feedback[-1].get("acceptance_type") == "revise":
|
||||
if latest_validation is not None:
|
||||
phase = f"revision_attempt_{attempt_index}"
|
||||
elif plan is not None and plan.is_team:
|
||||
phase = f"team_synthesis_attempt_{attempt_index}"
|
||||
@ -1012,14 +963,24 @@ class AgentService:
|
||||
)
|
||||
else:
|
||||
sections.append("Previously activated skills:\nNone")
|
||||
if task.feedback:
|
||||
history_lines = []
|
||||
for item in task.feedback[-5:]:
|
||||
kind = item.get("acceptance_type") or item.get("feedback_type")
|
||||
comment = item.get("comment") or ""
|
||||
run_id = item.get("run_id") or ""
|
||||
history_lines.append(f"- {kind} run={run_id}: {comment}".strip())
|
||||
sections.append("Task acceptance history:\n" + "\n".join(history_lines))
|
||||
if latest_validation is not None:
|
||||
validation_lines = [
|
||||
f"accepted: {latest_validation.accepted}",
|
||||
f"score: {latest_validation.score}",
|
||||
]
|
||||
if latest_validation.issues:
|
||||
validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues))
|
||||
if latest_validation.missing_requirements:
|
||||
validation_lines.append(
|
||||
"missing requirements:\n"
|
||||
+ "\n".join(f"- {item}" for item in latest_validation.missing_requirements)
|
||||
)
|
||||
if latest_validation.recommended_revision_prompt:
|
||||
validation_lines.append(
|
||||
"recommended revision:\n"
|
||||
+ latest_validation.recommended_revision_prompt
|
||||
)
|
||||
sections.append("Validation feedback:\n" + "\n".join(validation_lines))
|
||||
if plan is not None:
|
||||
plan_lines = [
|
||||
f"mode: {plan.mode}",
|
||||
@ -1122,36 +1083,6 @@ class AgentService:
|
||||
payloads.append(payload)
|
||||
return payloads
|
||||
|
||||
@staticmethod
|
||||
def _team_run_evidence(result: TeamRunResult | None) -> list[RunEvidence]:
|
||||
if result is None:
|
||||
return []
|
||||
return [node.evidence for node in result.node_results if node.evidence is not None]
|
||||
|
||||
def _build_task_evidence_packet(
|
||||
self,
|
||||
*,
|
||||
session_manager: Any,
|
||||
task: TaskRecord,
|
||||
attempt_index: int,
|
||||
result: AgentRunResult,
|
||||
team_result: TeamRunResult | None,
|
||||
) -> TaskEvidencePacket:
|
||||
main_run = EvidenceBuilder(session_manager).build_run_evidence(
|
||||
result.session_id,
|
||||
result.run_id,
|
||||
result.output_text,
|
||||
result.finish_reason,
|
||||
)
|
||||
return TaskEvidencePacket(
|
||||
task_id=task.task_id,
|
||||
attempt_index=attempt_index,
|
||||
main_run=main_run,
|
||||
team_runs=self._team_run_evidence(team_result),
|
||||
team_node_results=list(team_result.node_results) if team_result is not None else [],
|
||||
final_output=result.output_text,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _team_execution_context(plan: TaskExecutionPlan, result: TeamRunResult) -> str:
|
||||
node_lines = [
|
||||
@ -1288,8 +1219,7 @@ class AgentService:
|
||||
"inbound_metadata": dict(inbound.metadata),
|
||||
"task_id": getattr(result, "task_id", None),
|
||||
"task_status": getattr(result, "task_status", None),
|
||||
"evidence_status": "recorded" if getattr(result, "task_id", None) else None,
|
||||
"validation_result": None,
|
||||
"validation_result": getattr(result, "validation_result", None),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -50,11 +50,10 @@ class SessionProcessProjector:
|
||||
|
||||
for record in records:
|
||||
payload = dict(record.event_payload or {})
|
||||
run_record_for_event = run_records.get(str(record.run_id)) if record.run_id else None
|
||||
task_id = payload.get("task_id") or getattr(run_record_for_event, "task_id", None)
|
||||
task_id = payload.get("task_id")
|
||||
if not task_id:
|
||||
continue
|
||||
attempt_index = int(payload.get("attempt_index") or getattr(run_record_for_event, "attempt_index", None) or 1)
|
||||
attempt_index = int(payload.get("attempt_index") or 1)
|
||||
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
|
||||
created_at = _timestamp(record.timestamp)
|
||||
root = runs.setdefault(
|
||||
@ -74,70 +73,15 @@ class SessionProcessProjector:
|
||||
},
|
||||
)
|
||||
|
||||
if record.event_type == "assistant_message_added" and record.tool_calls:
|
||||
run_id = record.run_id or root_run_id
|
||||
parent_run_id = root_run_id if run_id != root_run_id else None
|
||||
for index, tool_call in enumerate(record.tool_calls):
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_name = _tool_call_name(tool_call)
|
||||
add_event(
|
||||
event_id=f"{_event_id(record, 'tool-call')}:{index}",
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
kind="tool_call_started",
|
||||
actor_type="tool",
|
||||
actor_id=tool_name,
|
||||
actor_name=tool_name,
|
||||
text=f"Calling tool: {tool_name}.",
|
||||
created_at=created_at,
|
||||
status="running",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "tool_call",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call.get("id"),
|
||||
"arguments": _tool_call_arguments(tool_call),
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "tool_result_recorded":
|
||||
run_id = record.run_id or root_run_id
|
||||
parent_run_id = root_run_id if run_id != root_run_id else None
|
||||
tool_name = str(record.tool_name or payload.get("tool_name") or "tool")
|
||||
add_event(
|
||||
event_id=_event_id(record, "tool-result"),
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
kind="tool_call_finished",
|
||||
actor_type="tool",
|
||||
actor_id=tool_name,
|
||||
actor_name=tool_name,
|
||||
text=_truncate(str(record.content or payload.get("error") or "")),
|
||||
created_at=created_at,
|
||||
status="done" if payload.get("success", True) else "error",
|
||||
metadata={
|
||||
**dict(payload),
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "tool_result",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": record.tool_call_id,
|
||||
"result_summary": _truncate(str(record.content or payload.get("error") or "")),
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_execution_planned":
|
||||
plan_mode = payload.get("plan_mode") or "single"
|
||||
if record.event_type == "task_execution_planned":
|
||||
strategy = payload.get("strategy") or "single"
|
||||
node_ids = payload.get("node_ids") or []
|
||||
root["title"] = f"{plan_mode} plan: {strategy}"
|
||||
root["title"] = f"{payload.get('plan_mode', 'single')} plan: {strategy}"
|
||||
root["summary"] = payload.get("reason") or ""
|
||||
root["metadata"] = {
|
||||
**root.get("metadata", {}),
|
||||
"plan_mode": plan_mode,
|
||||
"strategy": strategy,
|
||||
"plan_mode": payload.get("plan_mode"),
|
||||
"strategy": payload.get("strategy"),
|
||||
"node_ids": node_ids,
|
||||
"skill_queries": payload.get("skill_queries") or [],
|
||||
"selected_skill_names": payload.get("selected_skill_names") or [],
|
||||
@ -148,65 +92,36 @@ class SessionProcessProjector:
|
||||
add_event(
|
||||
event_id=_event_id(record, "planned"),
|
||||
run_id=root_run_id,
|
||||
kind="task_planned",
|
||||
kind="run_started",
|
||||
actor_type="system",
|
||||
actor_id="task",
|
||||
actor_name="Task Planner",
|
||||
text=f"Beaver planned {plan_mode} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||||
text=f"Planned {payload.get('plan_mode')} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||||
created_at=created_at,
|
||||
status="running",
|
||||
metadata={
|
||||
**root["metadata"],
|
||||
"timeline_type": "plan",
|
||||
"user_summary": f"Beaver will use {plan_mode} execution for this task.",
|
||||
},
|
||||
metadata=root["metadata"],
|
||||
)
|
||||
selected_skill_names = [
|
||||
str(item)
|
||||
for item in payload.get("selected_skill_names") or []
|
||||
if str(item).strip()
|
||||
]
|
||||
if selected_skill_names:
|
||||
add_event(
|
||||
event_id=_event_id(record, "skills"),
|
||||
run_id=root_run_id,
|
||||
kind="skill_selected",
|
||||
actor_type="system",
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(selected_skill_names)}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "skill",
|
||||
"skill_names": selected_skill_names,
|
||||
"reason": payload.get("reason") or "Selected from task planning context.",
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
|
||||
team_success = bool(payload.get("team_success"))
|
||||
root["status"] = "running"
|
||||
team_run_ids = payload.get("team_run_ids") or []
|
||||
root["metadata"] = {
|
||||
**root.get("metadata", {}),
|
||||
"team_success": team_success,
|
||||
"team_run_ids": team_run_ids,
|
||||
"team_run_ids": payload.get("team_run_ids") or [],
|
||||
"team_error": payload.get("error"),
|
||||
}
|
||||
add_event(
|
||||
event_id=_event_id(record, "team"),
|
||||
run_id=root_run_id,
|
||||
kind="agent_team_created",
|
||||
kind="run_status",
|
||||
actor_type="system",
|
||||
actor_id="team",
|
||||
actor_name="Task Team",
|
||||
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
|
||||
created_at=created_at,
|
||||
status="done" if team_success else "error",
|
||||
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
|
||||
metadata=dict(payload),
|
||||
)
|
||||
node_results = payload.get("node_results") or []
|
||||
for item in node_results:
|
||||
@ -277,26 +192,20 @@ class SessionProcessProjector:
|
||||
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
||||
run_id=str(node_run_id),
|
||||
parent_run_id=root_run_id,
|
||||
kind="agent_finished",
|
||||
kind="run_finished",
|
||||
actor_type="agent",
|
||||
actor_id=str(item.get("node_id") or "sub-agent"),
|
||||
actor_name=str(item.get("node_id") or "Sub-agent"),
|
||||
text=_truncate(str(item.get("output_text") or item.get("error") or "")),
|
||||
created_at=created_at,
|
||||
status=status,
|
||||
metadata={
|
||||
**dict(item),
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "agent_progress",
|
||||
},
|
||||
metadata=dict(item),
|
||||
)
|
||||
|
||||
elif record.event_type == "task_synthesis_completed":
|
||||
main_run_id = str(payload.get("main_run_id") or "")
|
||||
if main_run_id:
|
||||
run_record = run_records.get(main_run_id)
|
||||
activated_skill_names = _activated_skill_names(run_record)
|
||||
runs[main_run_id] = {
|
||||
"run_id": main_run_id,
|
||||
"parent_run_id": root_run_id,
|
||||
@ -310,32 +219,8 @@ class SessionProcessProjector:
|
||||
"started_at": run_record.started_at if run_record is not None else created_at,
|
||||
"finished_at": run_record.ended_at if run_record is not None else created_at,
|
||||
"summary": _truncate(run_record.task_text if run_record is not None else ""),
|
||||
"metadata": {
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"skill_names": activated_skill_names,
|
||||
},
|
||||
"metadata": {"task_id": task_id, "attempt_index": attempt_index},
|
||||
}
|
||||
if activated_skill_names:
|
||||
add_event(
|
||||
event_id=_event_id(record, "synthesis-skills"),
|
||||
run_id=main_run_id,
|
||||
parent_run_id=root_run_id,
|
||||
kind="skill_selected",
|
||||
actor_type="system",
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "skill",
|
||||
"skill_names": activated_skill_names,
|
||||
"activation_reasons": _activated_skill_reasons(run_record),
|
||||
},
|
||||
)
|
||||
add_event(
|
||||
event_id=_event_id(record, "synthesis"),
|
||||
run_id=main_run_id,
|
||||
@ -350,46 +235,27 @@ class SessionProcessProjector:
|
||||
metadata=dict(payload),
|
||||
)
|
||||
|
||||
elif record.event_type == "task_evidence_recorded":
|
||||
root["status"] = "waiting"
|
||||
root["finished_at"] = None
|
||||
elif record.event_type == "task_validation_snapshotted":
|
||||
validation = payload.get("validation_result") if isinstance(payload.get("validation_result"), dict) else {}
|
||||
accepted = bool(validation.get("accepted"))
|
||||
root["status"] = "done" if accepted or attempt_index == 2 else "waiting"
|
||||
root["finished_at"] = created_at if root["status"] == "done" else None
|
||||
add_event(
|
||||
event_id=_event_id(record, "evidence"),
|
||||
event_id=_event_id(record, "validation"),
|
||||
run_id=record.run_id or root_run_id,
|
||||
parent_run_id=root_run_id if record.run_id else None,
|
||||
kind="task_result_ready",
|
||||
kind="run_status",
|
||||
actor_type="system",
|
||||
actor_id="evidence-recorder",
|
||||
actor_name="Evidence",
|
||||
text="The task result is ready for user acceptance.",
|
||||
actor_id="validator",
|
||||
actor_name="Validator",
|
||||
text=(
|
||||
f"Validation {'passed' if accepted else 'failed'} "
|
||||
f"(score={validation.get('score')})."
|
||||
+ (" Retry scheduled." if payload.get("retry_scheduled") else "")
|
||||
),
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={**dict(payload), "timeline_type": "result"},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_acceptance_recorded":
|
||||
acceptance_type = str(payload.get("acceptance_type") or payload.get("feedback_type") or "")
|
||||
if acceptance_type == "accept":
|
||||
root["status"] = "done"
|
||||
root["finished_at"] = created_at
|
||||
elif acceptance_type == "abandon":
|
||||
root["status"] = "cancelled"
|
||||
root["finished_at"] = created_at
|
||||
else:
|
||||
root["status"] = "waiting"
|
||||
root["finished_at"] = None
|
||||
add_event(
|
||||
event_id=_event_id(record, "acceptance"),
|
||||
run_id=record.run_id or root_run_id,
|
||||
parent_run_id=root_run_id if record.run_id else None,
|
||||
kind="task_acceptance_recorded",
|
||||
actor_type="user",
|
||||
actor_id="user-acceptance",
|
||||
actor_name="User Acceptance",
|
||||
text=f"User acceptance recorded: {acceptance_type or 'unknown'}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={**dict(payload), "timeline_type": "acceptance"},
|
||||
status="done" if accepted else "error",
|
||||
metadata=dict(payload),
|
||||
)
|
||||
|
||||
return {
|
||||
@ -415,49 +281,3 @@ def _truncate(text: str, limit: int = 800) -> str:
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[: limit - 1] + "..."
|
||||
|
||||
|
||||
def _activated_skill_names(run_record: Any | None) -> list[str]:
|
||||
if run_record is None:
|
||||
return []
|
||||
names = []
|
||||
for receipt in getattr(run_record, "activated_skills", []) or []:
|
||||
skill_name = str(getattr(receipt, "skill_name", "") or "").strip()
|
||||
if skill_name:
|
||||
names.append(skill_name)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _activated_skill_reasons(run_record: Any | None) -> list[str]:
|
||||
if run_record is None:
|
||||
return []
|
||||
reasons = []
|
||||
for receipt in getattr(run_record, "activated_skills", []) or []:
|
||||
reason = str(getattr(receipt, "activation_reason", "") or "").strip()
|
||||
if reason:
|
||||
reasons.append(reason)
|
||||
return reasons
|
||||
|
||||
|
||||
def _tool_call_name(tool_call: dict[str, Any]) -> str:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict):
|
||||
name = function_payload.get("name")
|
||||
if name:
|
||||
return str(name)
|
||||
for key in ("name", "tool_name"):
|
||||
value = tool_call.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return "tool"
|
||||
|
||||
|
||||
def _tool_call_arguments(tool_call: dict[str, Any]) -> Any:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict) and "arguments" in function_payload:
|
||||
return function_payload.get("arguments")
|
||||
if "arguments" in tool_call:
|
||||
return tool_call.get("arguments")
|
||||
if "args" in tool_call:
|
||||
return tool_call.get("args")
|
||||
return None
|
||||
|
||||
@ -16,10 +16,10 @@ if TYPE_CHECKING:
|
||||
class TeamService:
|
||||
"""Internal service for Beaver-native multi-agent execution."""
|
||||
|
||||
def __init__(self, loop: AgentLoop, *, max_parallel_team_nodes: int = 3) -> None:
|
||||
def __init__(self, loop: AgentLoop) -> None:
|
||||
self.loop = loop
|
||||
self.runner = LocalAgentRunner(loop)
|
||||
self.scheduler = TeamGraphScheduler(self.runner, max_parallel_team_nodes=max_parallel_team_nodes)
|
||||
self.scheduler = TeamGraphScheduler(self.runner)
|
||||
|
||||
async def run_team(
|
||||
self,
|
||||
|
||||
201
app-instance/backend/beaver/services/user_file_resolver.py
Normal file
201
app-instance/backend/beaver/services/user_file_resolver.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""Resolve the user-visible file system for web and agent callers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from beaver.foundation.config.schema import BeaverConfig
|
||||
|
||||
from .user_files import (
|
||||
LocalUserFileStorage,
|
||||
MinIOStorageConfig,
|
||||
MinIOUserFileStorage,
|
||||
USER_FILE_ROOTS,
|
||||
UserFileError,
|
||||
UserFileService,
|
||||
)
|
||||
|
||||
|
||||
class UserFileConfigurationError(UserFileError):
|
||||
"""Raised when user file storage is not configured for this backend."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileAuthContext:
|
||||
"""Authenticated identity used by the personal file system boundary."""
|
||||
|
||||
username: str
|
||||
backend_id: str
|
||||
storage_namespace: str
|
||||
user_id: str | None = None
|
||||
scopes: tuple[str, ...] = field(default_factory=tuple)
|
||||
auth_source: str = "beaver-web-token"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFileStorageStatus:
|
||||
configured: bool
|
||||
storage_mode: str
|
||||
roots: list[str]
|
||||
workspace_visible: bool = False
|
||||
detail: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"configured": self.configured,
|
||||
"storage_mode": self.storage_mode,
|
||||
"roots": self.roots,
|
||||
"workspace_visible": self.workspace_visible,
|
||||
}
|
||||
if self.detail:
|
||||
payload["detail"] = self.detail
|
||||
return payload
|
||||
|
||||
|
||||
class UserFileStorageResolver:
|
||||
"""Build `UserFileService` from the current Beaver identity and config."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: BeaverConfig,
|
||||
workspace: Path,
|
||||
auth_context: FileAuthContext,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.workspace = Path(workspace)
|
||||
self.auth_context = auth_context
|
||||
|
||||
async def service(self) -> UserFileService:
|
||||
mode = _storage_mode(self.config)
|
||||
if mode == "local":
|
||||
return UserFileService(LocalUserFileStorage(self.workspace / "user_files"))
|
||||
settings = await self._load_minio_settings()
|
||||
return UserFileService(
|
||||
MinIOUserFileStorage(
|
||||
MinIOStorageConfig(
|
||||
endpoint=str(settings.get("endpoint") or ""),
|
||||
access_key=str(settings.get("access_key") or ""),
|
||||
secret_key=str(settings.get("secret_key") or ""),
|
||||
bucket=str(settings.get("bucket") or ""),
|
||||
secure=bool(settings.get("secure", False)),
|
||||
region=_clean_optional(settings.get("region")),
|
||||
namespace=str(settings.get("namespace") or self.auth_context.storage_namespace),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def status(self) -> UserFileStorageStatus:
|
||||
mode = _storage_mode(self.config)
|
||||
if mode == "local":
|
||||
return UserFileStorageStatus(
|
||||
configured=True,
|
||||
storage_mode="local",
|
||||
roots=list(USER_FILE_ROOTS),
|
||||
workspace_visible=False,
|
||||
)
|
||||
try:
|
||||
await self._load_minio_settings()
|
||||
except UserFileConfigurationError as exc:
|
||||
return UserFileStorageStatus(
|
||||
configured=False,
|
||||
storage_mode="object",
|
||||
roots=list(USER_FILE_ROOTS),
|
||||
workspace_visible=False,
|
||||
detail=str(exc),
|
||||
)
|
||||
return UserFileStorageStatus(
|
||||
configured=True,
|
||||
storage_mode="object",
|
||||
roots=list(USER_FILE_ROOTS),
|
||||
workspace_visible=False,
|
||||
)
|
||||
|
||||
async def _load_minio_settings(self) -> dict[str, Any]:
|
||||
backend_id = self.auth_context.backend_id.strip()
|
||||
if not backend_id:
|
||||
raise UserFileConfigurationError("User file storage backend identity is not configured")
|
||||
base_url = self.config.authz.base_url.strip()
|
||||
if not (self.config.authz.enabled and base_url):
|
||||
raise UserFileConfigurationError("AuthZ is required for deployed user file storage")
|
||||
token = (
|
||||
os.getenv("BEAVER_AUTHZ_INTERNAL_TOKEN", "").strip()
|
||||
or os.getenv("AUTHZ_INTERNAL_TOKEN", "").strip()
|
||||
)
|
||||
if not token:
|
||||
raise UserFileConfigurationError("AuthZ internal token is not configured for user file storage")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.config.authz.request_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
f"{base_url.rstrip('/')}/internal/backends/{backend_id}/settings/minio",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise UserFileConfigurationError(f"Unable to load user file storage settings: {exc}") from exc
|
||||
if response.status_code == 404:
|
||||
raise UserFileConfigurationError("MinIO user file storage is not configured")
|
||||
if response.is_error:
|
||||
raise UserFileConfigurationError(
|
||||
f"Unable to load user file storage settings: HTTP {response.status_code}"
|
||||
)
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise UserFileConfigurationError("Invalid MinIO settings response")
|
||||
if not all(str(payload.get(key) or "").strip() for key in ("endpoint", "access_key", "secret_key", "bucket")):
|
||||
raise UserFileConfigurationError("MinIO user file storage settings are incomplete")
|
||||
payload.setdefault("namespace", self.auth_context.storage_namespace)
|
||||
return payload
|
||||
|
||||
|
||||
def build_file_auth_context(
|
||||
*,
|
||||
username: str,
|
||||
config: BeaverConfig,
|
||||
user_id: str | None = None,
|
||||
scopes: tuple[str, ...] = (),
|
||||
auth_source: str = "beaver-web-token",
|
||||
) -> FileAuthContext:
|
||||
backend_id = (
|
||||
config.backend_identity.backend_id.strip()
|
||||
or os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID", "").strip()
|
||||
or username.strip()
|
||||
)
|
||||
namespace = default_user_file_namespace(backend_id)
|
||||
return FileAuthContext(
|
||||
username=username.strip(),
|
||||
backend_id=backend_id,
|
||||
storage_namespace=namespace,
|
||||
user_id=user_id,
|
||||
scopes=scopes,
|
||||
auth_source=auth_source,
|
||||
)
|
||||
|
||||
|
||||
def default_user_file_namespace(backend_id: str) -> str:
|
||||
cleaned = backend_id.strip().strip("/")
|
||||
return f"users/{cleaned}" if cleaned else "users/unconfigured"
|
||||
|
||||
|
||||
def _storage_mode(config: BeaverConfig) -> str:
|
||||
raw = os.getenv("BEAVER_USER_FILES_STORAGE_MODE", "").strip().lower()
|
||||
if raw in {"local", "dev-local", "development"}:
|
||||
return "local"
|
||||
if raw in {"minio", "object", "object-storage"}:
|
||||
return "minio"
|
||||
if config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip():
|
||||
return "minio"
|
||||
return "local"
|
||||
|
||||
|
||||
def _clean_optional(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
630
app-instance/backend/beaver/services/user_files.py
Normal file
630
app-instance/backend/beaver/services/user_files.py
Normal file
@ -0,0 +1,630 @@
|
||||
"""User-visible file system service.
|
||||
|
||||
This module owns the personal file-system boundary exposed to users and
|
||||
agents. Storage backends can change, but callers see only virtual paths under
|
||||
fixed roots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
import mimetypes
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
USER_FILE_ROOTS = ("uploads", "outputs", "shared", "tasks")
|
||||
MAX_PREVIEW_BYTES = 1024 * 1024
|
||||
AGENT_UPLOADS_ERROR = "uploads/ is user-provided input storage; agents may read it but must not write it"
|
||||
AGENT_DELETE_ERROR = "agents cannot delete user-visible files; use the Files page or user-side APIs"
|
||||
|
||||
|
||||
class UserFileError(ValueError):
|
||||
"""Base error for user file operations."""
|
||||
|
||||
|
||||
class UserFilePathError(UserFileError):
|
||||
"""Raised when a user file path violates the virtual path policy."""
|
||||
|
||||
|
||||
class UserFileNotFoundError(UserFileError):
|
||||
"""Raised when a user file path does not exist."""
|
||||
|
||||
|
||||
class UserFileSizeError(UserFileError):
|
||||
"""Raised when a user file upload exceeds configured limits."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentUserFilePolicy:
|
||||
task_id: str | None = None
|
||||
fallback_scope: str = "interactive"
|
||||
|
||||
@property
|
||||
def task_namespace(self) -> str:
|
||||
if self.task_id:
|
||||
return f"tasks/{self.task_id}"
|
||||
scope = _safe_scope(self.fallback_scope)
|
||||
return f"tasks/interactive/{scope}"
|
||||
|
||||
def validate_read(self, path: str) -> str:
|
||||
return normalize_user_path(path, allow_root=False)
|
||||
|
||||
def validate_write(self, path: str) -> str:
|
||||
normalized = normalize_user_path(path, allow_root=False)
|
||||
root = normalized.split("/", 1)[0]
|
||||
if root == "uploads":
|
||||
raise UserFilePathError(AGENT_UPLOADS_ERROR)
|
||||
if root == "tasks":
|
||||
self._validate_task_namespace(normalized)
|
||||
return normalized
|
||||
|
||||
def validate_mkdir(self, path: str) -> str:
|
||||
return self.validate_write(path)
|
||||
|
||||
def validate_delete(self, path: str) -> str:
|
||||
normalize_user_path(path, allow_root=False)
|
||||
raise UserFilePathError(AGENT_DELETE_ERROR)
|
||||
|
||||
def _validate_task_namespace(self, normalized: str) -> None:
|
||||
namespace = self.task_namespace
|
||||
if normalized == "tasks" or not normalized.startswith(f"{namespace}/"):
|
||||
raise UserFilePathError(f"Agent task files must be written under {namespace}/")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFileEntry:
|
||||
name: str
|
||||
path: str
|
||||
type: str
|
||||
size: int | None = None
|
||||
content_type: str | None = None
|
||||
modified: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"path": self.path,
|
||||
"type": self.type,
|
||||
"size": self.size,
|
||||
"content_type": self.content_type,
|
||||
"modified": self.modified,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFileContent:
|
||||
name: str
|
||||
path: str
|
||||
size: int
|
||||
content_type: str
|
||||
modified: str | None
|
||||
content: bytes
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilePreview:
|
||||
name: str
|
||||
path: str
|
||||
size: int
|
||||
content_type: str
|
||||
modified: str | None
|
||||
is_binary: bool
|
||||
is_truncated: bool
|
||||
content: str | None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"path": self.path,
|
||||
"size": self.size,
|
||||
"content_type": self.content_type,
|
||||
"modified": self.modified,
|
||||
"is_binary": self.is_binary,
|
||||
"is_truncated": self.is_truncated,
|
||||
"content": self.content,
|
||||
}
|
||||
|
||||
|
||||
class UserFileStorage(Protocol):
|
||||
async def list_dir(self, path: str) -> list[UserFileEntry]:
|
||||
...
|
||||
|
||||
async def read_file(self, path: str, *, max_bytes: int | None = None) -> UserFileContent:
|
||||
...
|
||||
|
||||
async def write_file(self, path: str, content: bytes, *, content_type: str) -> UserFileEntry:
|
||||
...
|
||||
|
||||
async def write_file_stream(
|
||||
self,
|
||||
path: str,
|
||||
stream: object,
|
||||
*,
|
||||
content_type: str,
|
||||
max_bytes: int | None = None,
|
||||
part_size: int = 10 * 1024 * 1024,
|
||||
) -> UserFileEntry:
|
||||
...
|
||||
|
||||
async def delete_path(self, path: str) -> bool:
|
||||
...
|
||||
|
||||
async def mkdir(self, path: str) -> UserFileEntry:
|
||||
...
|
||||
|
||||
|
||||
class UserFileService:
|
||||
def __init__(self, storage: UserFileStorage) -> None:
|
||||
self.storage = storage
|
||||
|
||||
async def browse(self, path: str = "") -> dict[str, object]:
|
||||
normalized = normalize_user_path(path, allow_root=True)
|
||||
if normalized == "":
|
||||
return {
|
||||
"path": "",
|
||||
"items": [
|
||||
UserFileEntry(name=root, path=root, type="directory").to_dict()
|
||||
for root in USER_FILE_ROOTS
|
||||
],
|
||||
}
|
||||
entries = await self.storage.list_dir(normalized)
|
||||
return {"path": normalized, "items": [entry.to_dict() for entry in entries]}
|
||||
|
||||
async def upload(self, directory: str, filename: str, content: bytes, *, content_type: str) -> dict[str, object]:
|
||||
if not is_safe_filename(filename):
|
||||
raise UserFilePathError("Invalid filename")
|
||||
target = normalize_user_path(_join_user_path(directory, filename), allow_root=False)
|
||||
return (await self.storage.write_file(target, content, content_type=content_type)).to_dict()
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
directory: str,
|
||||
filename: str,
|
||||
stream: object,
|
||||
*,
|
||||
content_type: str,
|
||||
max_bytes: int | None = None,
|
||||
part_size: int = 10 * 1024 * 1024,
|
||||
) -> dict[str, object]:
|
||||
if not is_safe_filename(filename):
|
||||
raise UserFilePathError("Invalid filename")
|
||||
target = normalize_user_path(_join_user_path(directory, filename), allow_root=False)
|
||||
return (
|
||||
await self.storage.write_file_stream(
|
||||
target,
|
||||
stream,
|
||||
content_type=content_type,
|
||||
max_bytes=max_bytes,
|
||||
part_size=part_size,
|
||||
)
|
||||
).to_dict()
|
||||
|
||||
async def write_file(self, path: str, content: bytes | str, *, content_type: str = "text/plain") -> dict[str, object]:
|
||||
normalized = normalize_user_path(path, allow_root=False)
|
||||
raw = content.encode("utf-8") if isinstance(content, str) else bytes(content)
|
||||
return (await self.storage.write_file(normalized, raw, content_type=content_type)).to_dict()
|
||||
|
||||
async def download(self, path: str) -> UserFileContent:
|
||||
return await self.storage.read_file(normalize_user_path(path, allow_root=False))
|
||||
|
||||
async def preview(self, path: str, *, max_bytes: int = MAX_PREVIEW_BYTES) -> dict[str, object]:
|
||||
content = await self.storage.read_file(normalize_user_path(path, allow_root=False), max_bytes=max_bytes)
|
||||
is_binary = _is_probably_binary(content.content, content.content_type)
|
||||
text = None if is_binary else content.content.decode("utf-8", errors="replace")
|
||||
return UserFilePreview(
|
||||
name=content.name,
|
||||
path=content.path,
|
||||
size=content.size,
|
||||
content_type=content.content_type,
|
||||
modified=content.modified,
|
||||
is_binary=is_binary,
|
||||
is_truncated=content.size > len(content.content),
|
||||
content=text,
|
||||
).to_dict()
|
||||
|
||||
async def delete(self, path: str) -> bool:
|
||||
normalized = normalize_user_path(path, allow_root=False)
|
||||
if normalized in USER_FILE_ROOTS:
|
||||
raise UserFilePathError("Cannot delete virtual root folders")
|
||||
return await self.storage.delete_path(normalized)
|
||||
|
||||
async def mkdir(self, path: str) -> dict[str, object]:
|
||||
normalized = normalize_user_path(path, allow_root=False)
|
||||
if normalized in USER_FILE_ROOTS:
|
||||
raise UserFilePathError("Virtual root folders already exist")
|
||||
return (await self.storage.mkdir(normalized)).to_dict()
|
||||
|
||||
|
||||
class LocalUserFileStorage:
|
||||
"""Filesystem-backed storage adapter for tests and local development."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root).expanduser().resolve()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
for name in USER_FILE_ROOTS:
|
||||
(self.root / name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def list_dir(self, path: str) -> list[UserFileEntry]:
|
||||
target = self._path(path)
|
||||
if not target.exists():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
if not target.is_dir():
|
||||
raise UserFilePathError("Path is not a directory")
|
||||
entries: list[UserFileEntry] = []
|
||||
for child in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
entries.append(self._entry(child))
|
||||
return entries
|
||||
|
||||
async def read_file(self, path: str, *, max_bytes: int | None = None) -> UserFileContent:
|
||||
target = self._path(path)
|
||||
if not target.is_file():
|
||||
raise UserFileNotFoundError("File not found")
|
||||
raw = target.read_bytes()
|
||||
selected = raw[:max_bytes] if max_bytes is not None else raw
|
||||
stat = target.stat()
|
||||
content_type, _ = mimetypes.guess_type(target.name)
|
||||
return UserFileContent(
|
||||
name=target.name,
|
||||
path=self._relative(target),
|
||||
size=stat.st_size,
|
||||
content_type=content_type or "application/octet-stream",
|
||||
modified=_iso_from_timestamp(stat.st_mtime),
|
||||
content=selected,
|
||||
)
|
||||
|
||||
async def write_file(self, path: str, content: bytes, *, content_type: str) -> UserFileEntry:
|
||||
target = self._path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
return self._entry(target, content_type=content_type)
|
||||
|
||||
async def write_file_stream(
|
||||
self,
|
||||
path: str,
|
||||
stream: object,
|
||||
*,
|
||||
content_type: str,
|
||||
max_bytes: int | None = None,
|
||||
part_size: int = 10 * 1024 * 1024,
|
||||
) -> UserFileEntry:
|
||||
target = self._path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
try:
|
||||
with open(fd, "wb", closefd=True) as output:
|
||||
while True:
|
||||
chunk = stream.read(part_size) # type: ignore[attr-defined]
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if max_bytes is not None and total > max_bytes:
|
||||
raise UserFileSizeError(_size_error(max_bytes))
|
||||
output.write(chunk)
|
||||
tmp_path.replace(target)
|
||||
except Exception:
|
||||
with suppress(FileNotFoundError):
|
||||
tmp_path.unlink()
|
||||
raise
|
||||
return self._entry(target, content_type=content_type)
|
||||
|
||||
async def delete_path(self, path: str) -> bool:
|
||||
target = self._path(path)
|
||||
if not target.exists():
|
||||
return False
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
return True
|
||||
|
||||
async def mkdir(self, path: str) -> UserFileEntry:
|
||||
target = self._path(path)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return self._entry(target)
|
||||
|
||||
def _path(self, path: str) -> Path:
|
||||
normalized = normalize_user_path(path, allow_root=False)
|
||||
target = (self.root / normalized).resolve()
|
||||
try:
|
||||
target.relative_to(self.root)
|
||||
except ValueError as exc:
|
||||
raise UserFilePathError("Path escapes user file root") from exc
|
||||
return target
|
||||
|
||||
def _relative(self, path: Path) -> str:
|
||||
return path.relative_to(self.root).as_posix()
|
||||
|
||||
def _entry(self, path: Path, *, content_type: str | None = None) -> UserFileEntry:
|
||||
stat = path.stat()
|
||||
guessed_type, _ = mimetypes.guess_type(path.name)
|
||||
return UserFileEntry(
|
||||
name=path.name,
|
||||
path=self._relative(path),
|
||||
type="directory" if path.is_dir() else "file",
|
||||
size=None if path.is_dir() else stat.st_size,
|
||||
content_type=None if path.is_dir() else (content_type or guessed_type or "application/octet-stream"),
|
||||
modified=_iso_from_timestamp(stat.st_mtime),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MinIOStorageConfig:
|
||||
endpoint: str
|
||||
access_key: str
|
||||
secret_key: str
|
||||
bucket: str
|
||||
secure: bool = False
|
||||
region: str | None = None
|
||||
namespace: str = ""
|
||||
|
||||
|
||||
class MinIOUserFileStorage:
|
||||
"""MinIO-backed user file storage adapter."""
|
||||
|
||||
def __init__(self, config: MinIOStorageConfig) -> None:
|
||||
if not config.endpoint or not config.access_key or not config.secret_key or not config.bucket:
|
||||
raise ValueError("MinIO storage requires endpoint, access key, secret key, and bucket")
|
||||
from minio import Minio
|
||||
|
||||
self.config = config
|
||||
self.client = Minio(
|
||||
endpoint=config.endpoint,
|
||||
access_key=config.access_key,
|
||||
secret_key=config.secret_key,
|
||||
secure=config.secure,
|
||||
region=config.region,
|
||||
)
|
||||
|
||||
async def list_dir(self, path: str) -> list[UserFileEntry]:
|
||||
prefix = self._object_prefix(path)
|
||||
objects = self.client.list_objects(self.config.bucket, prefix=prefix, recursive=False)
|
||||
entries: list[UserFileEntry] = []
|
||||
for obj in objects:
|
||||
object_name = str(obj.object_name or "")
|
||||
user_path = self._user_path(object_name)
|
||||
if not user_path or user_path == path or user_path.endswith("/.keep"):
|
||||
continue
|
||||
trimmed = user_path.rstrip("/")
|
||||
name = PurePosixPath(trimmed).name
|
||||
is_dir = bool(getattr(obj, "is_dir", False)) or object_name.endswith("/")
|
||||
entries.append(
|
||||
UserFileEntry(
|
||||
name=name,
|
||||
path=trimmed,
|
||||
type="directory" if is_dir else "file",
|
||||
size=None if is_dir else getattr(obj, "size", None),
|
||||
content_type=None if is_dir else "application/octet-stream",
|
||||
modified=obj.last_modified.isoformat() if getattr(obj, "last_modified", None) else None,
|
||||
)
|
||||
)
|
||||
return sorted(entries, key=lambda item: (item.type != "directory", item.name.lower()))
|
||||
|
||||
async def read_file(self, path: str, *, max_bytes: int | None = None) -> UserFileContent:
|
||||
object_name = self._object_name(path)
|
||||
try:
|
||||
stat = self.client.stat_object(self.config.bucket, object_name)
|
||||
if max_bytes is None:
|
||||
response = self.client.get_object(self.config.bucket, object_name)
|
||||
else:
|
||||
response = self.client.get_object(self.config.bucket, object_name, length=max_bytes)
|
||||
raw = response.read()
|
||||
response.close()
|
||||
response.release_conn()
|
||||
except Exception as exc:
|
||||
raise UserFileNotFoundError("File not found") from exc
|
||||
return UserFileContent(
|
||||
name=PurePosixPath(path).name,
|
||||
path=path,
|
||||
size=int(stat.size or len(raw)),
|
||||
content_type=stat.content_type or "application/octet-stream",
|
||||
modified=stat.last_modified.isoformat() if stat.last_modified else None,
|
||||
content=raw,
|
||||
)
|
||||
|
||||
async def write_file(self, path: str, content: bytes, *, content_type: str) -> UserFileEntry:
|
||||
object_name = self._object_name(path)
|
||||
result = self.client.put_object(
|
||||
self.config.bucket,
|
||||
object_name,
|
||||
BytesIO(content),
|
||||
length=len(content),
|
||||
content_type=content_type,
|
||||
)
|
||||
return UserFileEntry(
|
||||
name=PurePosixPath(path).name,
|
||||
path=path,
|
||||
type="file",
|
||||
size=len(content),
|
||||
content_type=content_type,
|
||||
modified=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
async def write_file_stream(
|
||||
self,
|
||||
path: str,
|
||||
stream: object,
|
||||
*,
|
||||
content_type: str,
|
||||
max_bytes: int | None = None,
|
||||
part_size: int = 10 * 1024 * 1024,
|
||||
) -> UserFileEntry:
|
||||
object_name = self._object_name(path)
|
||||
reader = _LimitedReadStream(stream, max_bytes=max_bytes)
|
||||
try:
|
||||
self.client.put_object(
|
||||
self.config.bucket,
|
||||
object_name,
|
||||
reader,
|
||||
length=-1,
|
||||
part_size=max(5 * 1024 * 1024, part_size),
|
||||
content_type=content_type,
|
||||
)
|
||||
except UserFileSizeError:
|
||||
try:
|
||||
self.client.remove_object(self.config.bucket, object_name)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return UserFileEntry(
|
||||
name=PurePosixPath(path).name,
|
||||
path=path,
|
||||
type="file",
|
||||
size=reader.bytes_read,
|
||||
content_type=content_type,
|
||||
modified=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
async def delete_path(self, path: str) -> bool:
|
||||
object_name = self._object_name(path)
|
||||
removed = False
|
||||
try:
|
||||
self.client.remove_object(self.config.bucket, object_name)
|
||||
removed = True
|
||||
except Exception:
|
||||
pass
|
||||
prefix = f"{object_name.rstrip('/')}/"
|
||||
for obj in self.client.list_objects(self.config.bucket, prefix=prefix, recursive=True):
|
||||
self.client.remove_object(self.config.bucket, str(obj.object_name))
|
||||
removed = True
|
||||
return removed
|
||||
|
||||
async def mkdir(self, path: str) -> UserFileEntry:
|
||||
object_name = f"{self._object_name(path).rstrip('/')}/.keep"
|
||||
self.client.put_object(
|
||||
self.config.bucket,
|
||||
object_name,
|
||||
BytesIO(b""),
|
||||
length=0,
|
||||
content_type="application/x-directory",
|
||||
)
|
||||
return UserFileEntry(
|
||||
name=PurePosixPath(path).name,
|
||||
path=path,
|
||||
type="directory",
|
||||
size=None,
|
||||
modified=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
def _namespace(self) -> str:
|
||||
return self.config.namespace.strip("/")
|
||||
|
||||
def _object_name(self, path: str) -> str:
|
||||
normalized = normalize_user_path(path, allow_root=False)
|
||||
namespace = self._namespace()
|
||||
object_name = f"{namespace}/{normalized}" if namespace else normalized
|
||||
if object_name.startswith("/") or "/../" in f"/{object_name}/":
|
||||
raise UserFilePathError("Object path escapes namespace")
|
||||
return object_name
|
||||
|
||||
def _object_prefix(self, path: str) -> str:
|
||||
return f"{self._object_name(path).rstrip('/')}/"
|
||||
|
||||
def _user_path(self, object_name: str) -> str:
|
||||
namespace = self._namespace()
|
||||
if namespace:
|
||||
prefix = f"{namespace}/"
|
||||
if not object_name.startswith(prefix):
|
||||
raise UserFilePathError("Object path escapes namespace")
|
||||
return object_name[len(prefix) :]
|
||||
return object_name
|
||||
|
||||
|
||||
def normalize_user_path(path: str | None, *, allow_root: bool) -> str:
|
||||
original = (path or "").replace("\\", "/").strip()
|
||||
if original.startswith("/"):
|
||||
raise UserFilePathError("Absolute paths are not allowed")
|
||||
raw = original.strip("/")
|
||||
if raw == "":
|
||||
if allow_root:
|
||||
return ""
|
||||
raise UserFilePathError("Path is required")
|
||||
posix = PurePosixPath(raw)
|
||||
if posix.is_absolute():
|
||||
raise UserFilePathError("Absolute paths are not allowed")
|
||||
parts = [part for part in posix.parts if part not in ("", ".")]
|
||||
if any(part == ".." for part in parts):
|
||||
raise UserFilePathError("Parent-directory traversal is not allowed")
|
||||
if any(part.startswith(".") for part in parts):
|
||||
raise UserFilePathError("Hidden implementation paths are not allowed")
|
||||
if not parts or parts[0] not in USER_FILE_ROOTS:
|
||||
raise UserFilePathError("Path must be under uploads, outputs, shared, or tasks")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def is_safe_filename(filename: str) -> bool:
|
||||
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
|
||||
|
||||
|
||||
def _join_user_path(directory: str, filename: str) -> str:
|
||||
normalized_dir = normalize_user_path(directory, allow_root=False)
|
||||
return f"{normalized_dir.rstrip('/')}/{filename}"
|
||||
|
||||
|
||||
def _is_probably_binary(raw: bytes, content_type: str) -> bool:
|
||||
if content_type.startswith("text/") or content_type in {
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
}:
|
||||
return False
|
||||
if not raw:
|
||||
return False
|
||||
if b"\x00" in raw[:4096]:
|
||||
return True
|
||||
try:
|
||||
raw[:4096].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _iso_from_timestamp(value: float) -> str:
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _safe_scope(value: str | None) -> str:
|
||||
raw = (value or "interactive").strip()
|
||||
allowed = [char if char.isalnum() or char in ("-", "_") else "-" for char in raw]
|
||||
cleaned = "".join(allowed).strip("-_")
|
||||
return cleaned or "interactive"
|
||||
|
||||
|
||||
class _LimitedReadStream:
|
||||
def __init__(self, stream: object, *, max_bytes: int | None = None) -> None:
|
||||
self.stream = stream
|
||||
self.max_bytes = max_bytes
|
||||
self.bytes_read = 0
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
chunk = self.stream.read(size) # type: ignore[attr-defined]
|
||||
if not chunk:
|
||||
return b""
|
||||
self.bytes_read += len(chunk)
|
||||
if self.max_bytes is not None and self.bytes_read > self.max_bytes:
|
||||
raise UserFileSizeError(_size_error(self.max_bytes))
|
||||
return chunk
|
||||
|
||||
|
||||
def _size_error(max_bytes: int) -> str:
|
||||
return f"File too large (max {_human_size(max_bytes)})"
|
||||
|
||||
|
||||
def _human_size(size: int) -> str:
|
||||
units = ("B", "KB", "MB", "GB", "TB")
|
||||
value = float(size)
|
||||
for unit in units:
|
||||
if value < 1024 or unit == units[-1]:
|
||||
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
|
||||
value /= 1024
|
||||
return f"{size}B"
|
||||
@ -69,24 +69,15 @@ class SkillLearningService:
|
||||
existing_ids.add(candidate.candidate_id)
|
||||
return candidates
|
||||
|
||||
def build_learning_candidates_for_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
final_accepted_run_id: str | None = None,
|
||||
trigger_run_id: str | None = None,
|
||||
) -> list[SkillLearningCandidate]:
|
||||
"""Build candidates from a user-accepted Task and all of its runs."""
|
||||
def build_learning_candidates_for_task(self, task_id: str, *, trigger_run_id: str) -> list[SkillLearningCandidate]:
|
||||
"""Build candidates scoped to a single validated and satisfied Task run."""
|
||||
|
||||
final_accepted_run_id = final_accepted_run_id or trigger_run_id
|
||||
if not final_accepted_run_id:
|
||||
return []
|
||||
runs = [record for record in self.run_store.list_runs() if record.task_id == task_id]
|
||||
final_run = next((record for record in runs if record.run_id == final_accepted_run_id), None)
|
||||
if final_run is None or not self._is_task_accepted_run(final_run):
|
||||
trigger_run = next((record for record in runs if record.run_id == trigger_run_id), None)
|
||||
if trigger_run is None or not self._is_confirmed_positive_run(trigger_run):
|
||||
return []
|
||||
|
||||
source_runs = sorted(runs, key=lambda item: (item.started_at, item.run_id))
|
||||
source_runs = [record for record in runs if self._is_confirmed_positive_run(record)]
|
||||
if not source_runs:
|
||||
return []
|
||||
|
||||
@ -109,16 +100,11 @@ class SkillLearningService:
|
||||
source_session_ids=source_session_ids,
|
||||
related_skill_names=[],
|
||||
reason=f"Task {task_id} completed successfully without a published skill; consider extracting reusable guidance.",
|
||||
evidence={
|
||||
"task_id": task_id,
|
||||
"final_accepted_run_id": final_accepted_run_id,
|
||||
"source_run_ids": source_run_ids,
|
||||
"theme": self._task_theme(final_run.task_text),
|
||||
},
|
||||
evidence={"task_id": task_id, "trigger_run_id": trigger_run_id, "theme": self._task_theme(trigger_run.task_text)},
|
||||
status="open",
|
||||
priority=1,
|
||||
confidence=0.8,
|
||||
trigger_reason="task_accepted",
|
||||
trigger_reason="validation_accepted_and_user_satisfied",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@ -151,14 +137,13 @@ class SkillLearningService:
|
||||
),
|
||||
evidence={
|
||||
"task_id": task_id,
|
||||
"final_accepted_run_id": final_accepted_run_id,
|
||||
"source_run_ids": source_run_ids,
|
||||
"trigger_run_id": trigger_run_id,
|
||||
"skill_version": receipt.skill_version,
|
||||
},
|
||||
status="open",
|
||||
priority=1,
|
||||
confidence=0.7,
|
||||
trigger_reason="task_accepted",
|
||||
trigger_reason="validation_accepted_and_user_satisfied",
|
||||
)
|
||||
)
|
||||
|
||||
@ -284,7 +269,7 @@ class SkillLearningService:
|
||||
groups.setdefault(key, []).append(record)
|
||||
candidates: list[SkillLearningCandidate] = []
|
||||
for theme, runs in groups.items():
|
||||
successful = [record for record in runs if self._is_task_accepted_run(record)]
|
||||
successful = [record for record in runs if self._is_confirmed_positive_run(record)]
|
||||
if len(successful) < 2:
|
||||
continue
|
||||
if any(record.activated_skills for record in successful):
|
||||
@ -305,7 +290,7 @@ class SkillLearningService:
|
||||
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
|
||||
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
|
||||
for record in self.run_store.list_runs():
|
||||
if not self._is_task_accepted_run(record):
|
||||
if not self._is_confirmed_positive_run(record):
|
||||
continue
|
||||
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
|
||||
for pair in combinations(unique, 2):
|
||||
@ -366,15 +351,14 @@ class SkillLearningService:
|
||||
return effects
|
||||
|
||||
@staticmethod
|
||||
def _is_task_accepted_run(record: RunRecord) -> bool:
|
||||
def _is_confirmed_positive_run(record: RunRecord) -> bool:
|
||||
validation = record.validation_result or {}
|
||||
feedback = record.feedback or {}
|
||||
acceptance_type = feedback.get("acceptance_type")
|
||||
if acceptance_type is None and feedback.get("feedback_type") == "satisfied":
|
||||
acceptance_type = "accept"
|
||||
return (
|
||||
bool(record.success)
|
||||
and bool(record.task_id)
|
||||
and acceptance_type == "accept"
|
||||
and validation.get("accepted") is True
|
||||
and feedback.get("feedback_type") == "satisfied"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -1,27 +1,22 @@
|
||||
"""Internal task tracking for automatic Main Agent task mode."""
|
||||
|
||||
from .evidence import EvidenceBuilder, RunEvidence, TaskEvidencePacket, ToolEvidence, render_task_evidence
|
||||
from .models import MainAgentDecision, TaskEvent, TaskRecord, ValidationResult, ValidationStatus
|
||||
from .models import MainAgentDecision, TaskEvent, TaskRecord, ValidationResult
|
||||
from .planner import TaskExecutionPlan, TaskExecutionPlanner
|
||||
from .router import MainAgentRouter
|
||||
from .service import TaskService
|
||||
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
||||
from .validation import ValidationService
|
||||
|
||||
__all__ = [
|
||||
"EvidenceBuilder",
|
||||
"MainAgentDecision",
|
||||
"MainAgentRouter",
|
||||
"RunEvidence",
|
||||
"TaskEvent",
|
||||
"TaskEvidencePacket",
|
||||
"TaskExecutionPlan",
|
||||
"TaskExecutionPlanner",
|
||||
"TaskRecord",
|
||||
"TaskService",
|
||||
"SkillResolutionReport",
|
||||
"TaskSkillResolver",
|
||||
"ToolEvidence",
|
||||
"ValidationResult",
|
||||
"ValidationStatus",
|
||||
"render_task_evidence",
|
||||
"ValidationService",
|
||||
]
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
"""Structured evidence for task synthesis and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolEvidence:
|
||||
tool_name: str
|
||||
tool_call_id: str | None
|
||||
content: str
|
||||
event_payload: dict[str, Any] = field(default_factory=dict)
|
||||
url: str | None = None
|
||||
title: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"tool_name": self.tool_name,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"content": self.content,
|
||||
"event_payload": dict(self.event_payload),
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunEvidence:
|
||||
run_id: str
|
||||
session_id: str
|
||||
output_text: str
|
||||
finish_reason: str
|
||||
transcript: list[dict[str, Any]] = field(default_factory=list)
|
||||
tool_results: list[ToolEvidence] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": self.run_id,
|
||||
"session_id": self.session_id,
|
||||
"output_text": self.output_text,
|
||||
"finish_reason": self.finish_reason,
|
||||
"transcript": list(self.transcript),
|
||||
"tool_results": [item.to_dict() for item in self.tool_results],
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskEvidencePacket:
|
||||
task_id: str
|
||||
attempt_index: int
|
||||
main_run: RunEvidence | None
|
||||
team_runs: list[RunEvidence] = field(default_factory=list)
|
||||
team_node_results: list[Any] = field(default_factory=list)
|
||||
final_output: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"attempt_index": self.attempt_index,
|
||||
"main_run": self.main_run.to_dict() if self.main_run else None,
|
||||
"team_runs": [item.to_dict() for item in self.team_runs],
|
||||
"team_node_results": [
|
||||
item.to_dict() if hasattr(item, "to_dict") else dict(item)
|
||||
for item in self.team_node_results
|
||||
],
|
||||
"final_output": self.final_output,
|
||||
}
|
||||
|
||||
|
||||
class EvidenceBuilder:
|
||||
def __init__(self, session_manager: Any) -> None:
|
||||
self.session_manager = session_manager
|
||||
|
||||
def build_run_evidence(
|
||||
self,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
output_text: str,
|
||||
finish_reason: str,
|
||||
) -> RunEvidence:
|
||||
events = self.session_manager.get_run_event_records(session_id, run_id)
|
||||
transcript: list[dict[str, Any]] = []
|
||||
tool_results: list[ToolEvidence] = []
|
||||
warnings: list[str] = []
|
||||
for event in events:
|
||||
payload = dict(event.event_payload or {})
|
||||
transcript.append(
|
||||
{
|
||||
"role": event.role,
|
||||
"event_type": event.event_type,
|
||||
"content": event.content,
|
||||
"tool_name": event.tool_name,
|
||||
"tool_call_id": event.tool_call_id,
|
||||
"finish_reason": event.finish_reason,
|
||||
"event_payload": payload,
|
||||
}
|
||||
)
|
||||
if event.event_type == "tool_result_recorded":
|
||||
tool_results.append(
|
||||
ToolEvidence(
|
||||
tool_name=event.tool_name or "tool",
|
||||
tool_call_id=event.tool_call_id,
|
||||
content=event.content or "",
|
||||
event_payload=payload,
|
||||
url=_optional_str(payload.get("url")),
|
||||
title=_optional_str(payload.get("title")),
|
||||
created_at=_optional_str(payload.get("created_at")),
|
||||
)
|
||||
)
|
||||
if finish_reason and finish_reason != "stop":
|
||||
warnings.append(f"finish_reason={finish_reason}")
|
||||
return RunEvidence(
|
||||
run_id=run_id,
|
||||
session_id=session_id,
|
||||
output_text=output_text,
|
||||
finish_reason=finish_reason,
|
||||
transcript=transcript,
|
||||
tool_results=tool_results,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def render_task_evidence(packet: TaskEvidencePacket) -> str:
|
||||
sections = [
|
||||
f"Task evidence packet: task_id={packet.task_id} attempt={packet.attempt_index}",
|
||||
f"Final output:\n{packet.final_output}",
|
||||
]
|
||||
if packet.main_run is not None:
|
||||
sections.append("Main run evidence:\n" + render_run_evidence(packet.main_run))
|
||||
if packet.team_runs:
|
||||
sections.append(
|
||||
"Team run evidence:\n"
|
||||
+ "\n\n".join(render_run_evidence(item) for item in packet.team_runs)
|
||||
)
|
||||
if packet.team_node_results:
|
||||
lines = []
|
||||
for item in packet.team_node_results:
|
||||
lines.append(
|
||||
f"- {getattr(item, 'node_id', '')}: success={getattr(item, 'success', False)} "
|
||||
f"finish_reason={getattr(item, 'finish_reason', '')} error={getattr(item, 'error', '') or ''}"
|
||||
)
|
||||
sections.append("Team node results:\n" + "\n".join(lines))
|
||||
return "\n\n".join(section for section in sections if section.strip())
|
||||
|
||||
|
||||
def render_run_evidence(evidence: RunEvidence) -> str:
|
||||
lines = [
|
||||
f"run_id={evidence.run_id}",
|
||||
f"session_id={evidence.session_id}",
|
||||
f"finish_reason={evidence.finish_reason}",
|
||||
]
|
||||
if evidence.output_text:
|
||||
lines.append(f"output:\n{evidence.output_text}")
|
||||
if evidence.warnings:
|
||||
lines.append("warnings:\n" + "\n".join(f"- {item}" for item in evidence.warnings))
|
||||
if evidence.tool_results:
|
||||
lines.append(
|
||||
"tool_results:\n"
|
||||
+ "\n\n".join(_render_tool_evidence(item) for item in evidence.tool_results)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_tool_evidence(item: ToolEvidence) -> str:
|
||||
header = f"- tool={item.tool_name} call_id={item.tool_call_id or ''}"
|
||||
metadata = []
|
||||
if item.url:
|
||||
metadata.append(f"url={item.url}")
|
||||
if item.title:
|
||||
metadata.append(f"title={item.title}")
|
||||
if item.created_at:
|
||||
metadata.append(f"created_at={item.created_at}")
|
||||
return "\n".join([header, *metadata, item.content])
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
@ -1,70 +1,33 @@
|
||||
"""Models for internal task tracking and user acceptance."""
|
||||
"""Models for internal task tracking and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
|
||||
ValidationStatus = Literal["accepted", "rejected", "insufficient_evidence", "validator_error"]
|
||||
|
||||
VALIDATION_STATUSES = {"accepted", "rejected", "insufficient_evidence", "validator_error"}
|
||||
TASK_OPEN_STATUSES = {"open", "running", "awaiting_acceptance", "needs_revision"}
|
||||
LEGACY_STATUS_MAP = {
|
||||
"validating": "running",
|
||||
"awaiting_feedback": "awaiting_acceptance",
|
||||
"needs_review": "awaiting_acceptance",
|
||||
}
|
||||
TASK_OPEN_STATUSES = {"open", "running", "validating", "awaiting_feedback", "needs_revision"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidationResult:
|
||||
status: ValidationStatus = "rejected"
|
||||
score: float = 0.0
|
||||
passed: bool
|
||||
score: float
|
||||
issues: list[str] = field(default_factory=list)
|
||||
missing_requirements: list[str] = field(default_factory=list)
|
||||
evidence_gaps: list[str] = field(default_factory=list)
|
||||
recommended_revision_prompt: str = ""
|
||||
validator: str = "heuristic"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: ValidationStatus | None = None,
|
||||
passed: bool | None = None,
|
||||
score: float = 0.0,
|
||||
issues: list[str] | None = None,
|
||||
missing_requirements: list[str] | None = None,
|
||||
evidence_gaps: list[str] | None = None,
|
||||
recommended_revision_prompt: str = "",
|
||||
validator: str = "heuristic",
|
||||
) -> None:
|
||||
if status is not None and status not in VALIDATION_STATUSES:
|
||||
raise ValueError(f"unknown validation status: {status}")
|
||||
self.status = status or ("accepted" if passed and score >= 0.75 else "rejected")
|
||||
self.score = max(0.0, min(1.0, float(score or 0.0)))
|
||||
self.issues = list(issues or [])
|
||||
self.missing_requirements = list(missing_requirements or [])
|
||||
self.evidence_gaps = list(evidence_gaps or [])
|
||||
self.recommended_revision_prompt = recommended_revision_prompt
|
||||
self.validator = validator
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return self.status == "accepted"
|
||||
|
||||
@property
|
||||
def accepted(self) -> bool:
|
||||
return self.status == "accepted"
|
||||
return self.passed and self.score >= 0.75
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"passed": self.passed,
|
||||
"score": self.score,
|
||||
"issues": list(self.issues),
|
||||
"missing_requirements": list(self.missing_requirements),
|
||||
"evidence_gaps": list(self.evidence_gaps),
|
||||
"recommended_revision_prompt": self.recommended_revision_prompt,
|
||||
"validator": self.validator,
|
||||
"accepted": self.accepted,
|
||||
@ -74,17 +37,11 @@ class ValidationResult:
|
||||
def from_dict(cls, payload: dict[str, Any] | None) -> "ValidationResult | None":
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
raw_status = payload.get("status")
|
||||
if "status" in payload and raw_status not in VALIDATION_STATUSES:
|
||||
raise ValueError(f"unknown validation status: {raw_status}")
|
||||
status: ValidationStatus | None = raw_status if "status" in payload else None
|
||||
return cls(
|
||||
status=status,
|
||||
passed=bool(payload.get("passed")) if "status" not in payload else None,
|
||||
passed=bool(payload.get("passed")),
|
||||
score=float(payload.get("score", 0.0) or 0.0),
|
||||
issues=[str(item) for item in payload.get("issues") or []],
|
||||
missing_requirements=[str(item) for item in payload.get("missing_requirements") or []],
|
||||
evidence_gaps=[str(item) for item in payload.get("evidence_gaps") or []],
|
||||
recommended_revision_prompt=str(payload.get("recommended_revision_prompt") or ""),
|
||||
validator=str(payload.get("validator") or "unknown"),
|
||||
)
|
||||
@ -116,14 +73,6 @@ class TaskRecord:
|
||||
def is_open(self) -> bool:
|
||||
return self.status in TASK_OPEN_STATUSES
|
||||
|
||||
@property
|
||||
def is_execution_active(self) -> bool:
|
||||
return self.status == "running"
|
||||
|
||||
@property
|
||||
def requires_user_action(self) -> bool:
|
||||
return self.status in {"awaiting_acceptance", "needs_revision"}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
@ -142,7 +91,6 @@ class TaskRecord:
|
||||
"satisfaction": self.satisfaction,
|
||||
"run_ids": list(self.run_ids),
|
||||
"skill_names": list(self.skill_names),
|
||||
"acceptance": list(self.feedback),
|
||||
"feedback": list(self.feedback),
|
||||
"validation_result": self.validation_result,
|
||||
"metadata": dict(self.metadata),
|
||||
@ -158,7 +106,7 @@ class TaskRecord:
|
||||
goal=str(payload.get("goal") or payload.get("description") or ""),
|
||||
constraints=[str(item) for item in payload.get("constraints") or []],
|
||||
priority=int(payload.get("priority", 0) or 0),
|
||||
status=LEGACY_STATUS_MAP.get(str(payload.get("status") or "open"), str(payload.get("status") or "open")),
|
||||
status=str(payload.get("status") or "open"),
|
||||
creator=str(payload.get("creator") or "main-agent"),
|
||||
created_at=str(payload.get("created_at") or ""),
|
||||
updated_at=str(payload.get("updated_at") or ""),
|
||||
@ -167,11 +115,7 @@ class TaskRecord:
|
||||
satisfaction=_optional_float(payload.get("satisfaction")),
|
||||
run_ids=[str(item) for item in payload.get("run_ids") or []],
|
||||
skill_names=[str(item) for item in payload.get("skill_names") or []],
|
||||
feedback=[
|
||||
_normalize_acceptance_entry(dict(item))
|
||||
for item in (payload.get("acceptance") or payload.get("feedback") or [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
feedback=[dict(item) for item in payload.get("feedback") or [] if isinstance(item, dict)],
|
||||
validation_result=dict(payload["validation_result"]) if isinstance(payload.get("validation_result"), dict) else None,
|
||||
metadata=dict(payload.get("metadata") or {}),
|
||||
)
|
||||
@ -236,13 +180,3 @@ def _optional_float(value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _normalize_acceptance_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
if entry.get("acceptance_type") is None and entry.get("feedback_type") is not None:
|
||||
feedback_type = str(entry.get("feedback_type") or "")
|
||||
entry["acceptance_type"] = "accept" if feedback_type == "satisfied" else feedback_type
|
||||
if entry.get("feedback_type") is None and entry.get("acceptance_type") is not None:
|
||||
acceptance_type = str(entry.get("acceptance_type") or "")
|
||||
entry["feedback_type"] = "satisfied" if acceptance_type == "accept" else acceptance_type
|
||||
return entry
|
||||
|
||||
@ -10,7 +10,7 @@ from typing import Any, Literal
|
||||
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
|
||||
from .models import TaskRecord
|
||||
from .models import TaskRecord, ValidationResult
|
||||
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
||||
|
||||
|
||||
@ -76,6 +76,7 @@ class TaskExecutionPlanner:
|
||||
task: TaskRecord,
|
||||
user_message: str,
|
||||
attempt_index: int,
|
||||
latest_validation: ValidationResult | None = None,
|
||||
provider_bundle: ProviderBundle | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> TaskExecutionPlan:
|
||||
@ -104,6 +105,7 @@ class TaskExecutionPlanner:
|
||||
task=task,
|
||||
user_message=user_message,
|
||||
attempt_index=attempt_index,
|
||||
latest_validation=latest_validation,
|
||||
),
|
||||
},
|
||||
],
|
||||
@ -228,10 +230,14 @@ class TaskExecutionPlanner:
|
||||
task: TaskRecord,
|
||||
user_message: str,
|
||||
attempt_index: int,
|
||||
latest_validation: ValidationResult | None,
|
||||
) -> str:
|
||||
history_note = ""
|
||||
if task.feedback:
|
||||
history_note = "\nRelevant task history:\n" + json.dumps(task.feedback[-5:], ensure_ascii=False)
|
||||
validation_note = ""
|
||||
if latest_validation is not None:
|
||||
validation_note = (
|
||||
"\nPrevious validation issues:\n"
|
||||
+ json.dumps(latest_validation.to_dict(), ensure_ascii=False)
|
||||
)
|
||||
return (
|
||||
"Decide execution mode for this internal Task attempt.\n"
|
||||
"Use mode=team only when independent research, review, implementation slices, or staged checks "
|
||||
@ -248,7 +254,7 @@ class TaskExecutionPlanner:
|
||||
f"Task goal:\n{task.goal}\n\n"
|
||||
f"Current user request:\n{user_message}\n\n"
|
||||
f"Attempt index: {attempt_index}\n"
|
||||
f"{history_note}"
|
||||
f"{validation_note}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .models import TaskEvent, TaskRecord
|
||||
from .models import TaskEvent, TaskRecord, ValidationResult
|
||||
from .store import TaskStore
|
||||
|
||||
|
||||
@ -77,8 +77,6 @@ class TaskService:
|
||||
payload = task.to_dict()
|
||||
payload["short_title"] = self.ensure_short_title(task).metadata.get("short_title")
|
||||
payload["is_open"] = task.is_open
|
||||
payload["is_execution_active"] = task.is_execution_active
|
||||
payload["requires_user_action"] = task.requires_user_action
|
||||
return payload
|
||||
|
||||
def ensure_short_title(self, task: TaskRecord) -> TaskRecord:
|
||||
@ -105,70 +103,18 @@ class TaskService:
|
||||
for name in skill_names or []:
|
||||
if name not in task.skill_names:
|
||||
task.skill_names.append(name)
|
||||
task.status = "awaiting_acceptance"
|
||||
task.updated_at = self._now()
|
||||
self.store.upsert_task(task)
|
||||
self._event(task, "run_completed", run_id=run_id, payload={"skill_names": skill_names or []})
|
||||
self._event(task, "evidence_recorded", run_id=run_id, payload={"skill_names": skill_names or []})
|
||||
return task
|
||||
|
||||
def add_acceptance(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
acceptance_type: str,
|
||||
comment: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> TaskRecord:
|
||||
def record_validation(self, task_id: str, run_id: str, validation: ValidationResult) -> TaskRecord:
|
||||
task = self._require(task_id)
|
||||
now = self._now()
|
||||
normalized = normalize_acceptance_type(acceptance_type)
|
||||
matching_acceptance = any(
|
||||
item.get("run_id") == run_id and item.get("acceptance_type") == normalized
|
||||
for item in task.feedback
|
||||
)
|
||||
conflicting_acceptance = next(
|
||||
(
|
||||
item
|
||||
for item in task.feedback
|
||||
if item.get("run_id") == run_id and item.get("acceptance_type") != normalized
|
||||
),
|
||||
None,
|
||||
)
|
||||
if conflicting_acceptance is not None:
|
||||
raise ValueError(
|
||||
f"Acceptance for run_id={run_id!r} was already recorded as "
|
||||
f"{conflicting_acceptance.get('acceptance_type')!r}"
|
||||
)
|
||||
if task.status in {"closed", "abandoned"} and not matching_acceptance:
|
||||
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
|
||||
if matching_acceptance:
|
||||
return task
|
||||
|
||||
entry = {
|
||||
"acceptance_type": normalized,
|
||||
"feedback_type": "satisfied" if normalized == "accept" else normalized,
|
||||
"comment": comment or "",
|
||||
"run_id": run_id,
|
||||
"created_at": now,
|
||||
}
|
||||
task.feedback.append(entry)
|
||||
if normalized == "revise":
|
||||
task.status = "needs_revision"
|
||||
elif normalized == "abandon":
|
||||
task.status = "abandoned"
|
||||
task.closed_at = now
|
||||
task.close_reason = comment or "abandoned"
|
||||
elif normalized == "accept":
|
||||
task.status = "closed"
|
||||
task.closed_at = now
|
||||
task.close_reason = "accepted"
|
||||
task.satisfaction = 1.0
|
||||
if run_id:
|
||||
task.metadata["final_accepted_run_id"] = run_id
|
||||
task.updated_at = now
|
||||
task.status = "awaiting_feedback"
|
||||
task.updated_at = self._now()
|
||||
task.validation_result = validation.to_dict()
|
||||
self.store.upsert_task(task)
|
||||
self._event(task, f"acceptance_{normalized}", run_id=run_id, payload=entry)
|
||||
self._event(task, "validated", run_id=run_id, payload=validation.to_dict())
|
||||
return task
|
||||
|
||||
def add_feedback(
|
||||
@ -179,12 +125,52 @@ class TaskService:
|
||||
comment: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> TaskRecord:
|
||||
return self.add_acceptance(
|
||||
task_id,
|
||||
acceptance_type=feedback_type,
|
||||
comment=comment,
|
||||
run_id=run_id,
|
||||
task = self._require(task_id)
|
||||
now = self._now()
|
||||
matching_feedback = any(
|
||||
item.get("run_id") == run_id and item.get("feedback_type") == feedback_type
|
||||
for item in task.feedback
|
||||
)
|
||||
conflicting_feedback = next(
|
||||
(
|
||||
item
|
||||
for item in task.feedback
|
||||
if item.get("run_id") == run_id and item.get("feedback_type") != feedback_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if conflicting_feedback is not None:
|
||||
raise ValueError(
|
||||
f"Feedback for run_id={run_id!r} was already recorded as "
|
||||
f"{conflicting_feedback.get('feedback_type')!r}"
|
||||
)
|
||||
if task.status in {"closed", "abandoned"} and not matching_feedback:
|
||||
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
|
||||
if matching_feedback:
|
||||
return task
|
||||
|
||||
entry = {
|
||||
"feedback_type": feedback_type,
|
||||
"comment": comment or "",
|
||||
"run_id": run_id,
|
||||
"created_at": now,
|
||||
}
|
||||
task.feedback.append(entry)
|
||||
if feedback_type == "revise":
|
||||
task.status = "needs_revision"
|
||||
elif feedback_type == "abandon":
|
||||
task.status = "abandoned"
|
||||
task.closed_at = now
|
||||
task.close_reason = comment or "abandoned"
|
||||
elif feedback_type == "satisfied":
|
||||
task.status = "closed"
|
||||
task.closed_at = now
|
||||
task.close_reason = "satisfied"
|
||||
task.satisfaction = 1.0
|
||||
task.updated_at = now
|
||||
self.store.upsert_task(task)
|
||||
self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry)
|
||||
return task
|
||||
|
||||
def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord:
|
||||
task = self._require(task_id)
|
||||
@ -259,12 +245,3 @@ def short_task_title(text: str) -> str:
|
||||
if len(words) <= 4:
|
||||
return cleaned[:40]
|
||||
return " ".join(words[:4])[:40]
|
||||
|
||||
|
||||
def normalize_acceptance_type(value: str) -> str:
|
||||
normalized = (value or "").strip().lower()
|
||||
if normalized == "satisfied":
|
||||
return "accept"
|
||||
if normalized not in {"accept", "revise", "abandon"}:
|
||||
raise ValueError("acceptance_type must be one of: accept, revise, abandon")
|
||||
return normalized
|
||||
|
||||
138
app-instance/backend/beaver/tasks/validation.py
Normal file
138
app-instance/backend/beaver/tasks/validation.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""Automatic validation for internal Task mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine.providers import ProviderBundle
|
||||
|
||||
from .models import TaskRecord, ValidationResult
|
||||
|
||||
|
||||
class ValidationService:
|
||||
async def validate_task_result(
|
||||
self,
|
||||
*,
|
||||
task: TaskRecord,
|
||||
user_message: str,
|
||||
final_output: str,
|
||||
transcript_excerpt: str = "",
|
||||
tool_summaries: list[str] | None = None,
|
||||
team_summaries: list[str] | None = None,
|
||||
provider_bundle: ProviderBundle | None = None,
|
||||
) -> ValidationResult:
|
||||
provider = None
|
||||
model = None
|
||||
if provider_bundle is not None:
|
||||
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
|
||||
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
|
||||
model = getattr(runtime, "model", None)
|
||||
if provider is not None:
|
||||
try:
|
||||
return await self._validate_with_provider(
|
||||
provider=provider,
|
||||
model=model,
|
||||
task=task,
|
||||
user_message=user_message,
|
||||
final_output=final_output,
|
||||
transcript_excerpt=transcript_excerpt,
|
||||
tool_summaries=tool_summaries or [],
|
||||
team_summaries=team_summaries or [],
|
||||
)
|
||||
except Exception as exc:
|
||||
return ValidationResult(
|
||||
passed=False,
|
||||
score=0.0,
|
||||
issues=[f"Validator failed: {exc}"],
|
||||
missing_requirements=["A valid automatic validation result is required before accepting the task."],
|
||||
recommended_revision_prompt=(
|
||||
"Review the task result again because automatic validation failed, "
|
||||
"then provide a corrected final answer that explicitly satisfies the task goal."
|
||||
),
|
||||
validator="llm_error",
|
||||
)
|
||||
return self._heuristic_validate(final_output)
|
||||
|
||||
async def _validate_with_provider(
|
||||
self,
|
||||
*,
|
||||
provider: Any,
|
||||
model: str | None,
|
||||
task: TaskRecord,
|
||||
user_message: str,
|
||||
final_output: str,
|
||||
transcript_excerpt: str,
|
||||
tool_summaries: list[str],
|
||||
team_summaries: list[str],
|
||||
) -> ValidationResult:
|
||||
prompt = (
|
||||
"Validate whether the assistant output satisfies the task. "
|
||||
"Return only compact JSON with keys: passed, score, issues, "
|
||||
"missing_requirements, recommended_revision_prompt.\n\n"
|
||||
f"Task goal:\n{task.goal}\n\n"
|
||||
f"Current user request:\n{user_message}\n\n"
|
||||
f"Transcript excerpt:\n{transcript_excerpt[:2500]}\n\n"
|
||||
f"Tool summaries:\n{json.dumps(tool_summaries[:12], ensure_ascii=False)}\n\n"
|
||||
f"Team summaries:\n{json.dumps(team_summaries[:12], ensure_ascii=False)}\n\n"
|
||||
f"Assistant final output:\n{final_output[:4000]}"
|
||||
)
|
||||
response = await provider.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a strict task result validator."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=None,
|
||||
model=model,
|
||||
max_tokens=4096,
|
||||
temperature=0.0,
|
||||
)
|
||||
payload = self._parse_json_object(response.content or "")
|
||||
return ValidationResult(
|
||||
passed=bool(payload.get("passed")),
|
||||
score=max(0.0, min(1.0, float(payload.get("score", 0.0) or 0.0))),
|
||||
issues=[str(item) for item in payload.get("issues") or []],
|
||||
missing_requirements=[str(item) for item in payload.get("missing_requirements") or []],
|
||||
recommended_revision_prompt=str(payload.get("recommended_revision_prompt") or ""),
|
||||
validator="llm",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _heuristic_validate(final_output: str) -> ValidationResult:
|
||||
text = final_output.strip()
|
||||
if not text:
|
||||
return ValidationResult(
|
||||
passed=False,
|
||||
score=0.0,
|
||||
issues=["Assistant output is empty."],
|
||||
missing_requirements=["A non-empty result is required."],
|
||||
recommended_revision_prompt="Produce a complete, non-empty answer for the task.",
|
||||
validator="heuristic",
|
||||
)
|
||||
lowered = text.lower()
|
||||
if "run failed before completion" in lowered or "tool loop stopped" in lowered:
|
||||
return ValidationResult(
|
||||
passed=False,
|
||||
score=0.35,
|
||||
issues=["The run did not complete cleanly."],
|
||||
missing_requirements=["A successful final result is required."],
|
||||
recommended_revision_prompt="Retry the task and address the failure before returning the final answer.",
|
||||
validator="heuristic",
|
||||
)
|
||||
return ValidationResult(passed=True, score=0.85, validator="heuristic")
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict[str, Any]:
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:].strip()
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end >= start:
|
||||
cleaned = cleaned[start : end + 1]
|
||||
payload = json.loads(cleaned)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("validator response must be a JSON object")
|
||||
return payload
|
||||
@ -180,8 +180,10 @@ class ObjectBackedTool(BaseTool):
|
||||
|
||||
if "current_session_id" not in arguments and hasattr(self.backend, "current_session_id"):
|
||||
arguments["current_session_id"] = context.session_id
|
||||
if "workspace" not in arguments and hasattr(self.backend, "workspace"):
|
||||
if "workspace" not in arguments and (hasattr(self.backend, "workspace") or self._backend_accepts_argument("workspace")):
|
||||
arguments["workspace"] = context.workspace
|
||||
if "services" not in arguments and self._backend_accepts_argument("services"):
|
||||
arguments["services"] = context.services
|
||||
if "metadata" not in arguments and self._backend_accepts_argument("metadata"):
|
||||
arguments["metadata"] = context.metadata
|
||||
|
||||
|
||||
@ -9,6 +9,15 @@ from .skill_view import SkillViewTool, skill_view
|
||||
from .session_search import SessionSearchTool, session_search
|
||||
from .terminal import ExecuteCodeTool, ProcessTool, TerminalTool
|
||||
from .utility import ClarifyTool, DelegateTool, SendMessageTool, SpawnTool, TodoTool
|
||||
from .user_files import (
|
||||
UserFilesCopyToWorkspaceTool,
|
||||
UserFilesDeleteTool,
|
||||
UserFilesListTool,
|
||||
UserFilesMkdirTool,
|
||||
UserFilesPublishOutputTool,
|
||||
UserFilesReadTool,
|
||||
UserFilesWriteTool,
|
||||
)
|
||||
from .web import WebFetchTool, WebSearchTool
|
||||
|
||||
__all__ = [
|
||||
@ -30,6 +39,13 @@ __all__ = [
|
||||
"SessionSearchTool",
|
||||
"TerminalTool",
|
||||
"TodoTool",
|
||||
"UserFilesCopyToWorkspaceTool",
|
||||
"UserFilesDeleteTool",
|
||||
"UserFilesListTool",
|
||||
"UserFilesMkdirTool",
|
||||
"UserFilesPublishOutputTool",
|
||||
"UserFilesReadTool",
|
||||
"UserFilesWriteTool",
|
||||
"ClarifyTool",
|
||||
"WebFetchTool",
|
||||
"WebSearchTool",
|
||||
|
||||
@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ MAX_READ_CHARS = 120_000
|
||||
MAX_SEARCH_RESULTS = 200
|
||||
MAX_SEARCH_FILE_BYTES = 2_000_000
|
||||
MAX_SEARCH_FILES = 5_000
|
||||
USER_FILE_VIRTUAL_ROOTS = {"uploads", "outputs", "shared", "tasks"}
|
||||
SKIP_DIR_NAMES = {
|
||||
".git",
|
||||
".hg",
|
||||
@ -161,9 +162,28 @@ def _workspace_root(workspace: str | None) -> Path:
|
||||
return root
|
||||
|
||||
|
||||
def _virtual_user_file_error(user_path: str | None) -> str | None:
|
||||
raw = str(user_path or ".").replace("\\", "/").strip()
|
||||
if not raw or raw in {".", "./"}:
|
||||
return None
|
||||
try:
|
||||
parts = [part for part in PurePosixPath(raw.strip("/")).parts if part not in ("", ".")]
|
||||
except TypeError:
|
||||
return None
|
||||
if parts and parts[0] in USER_FILE_VIRTUAL_ROOTS:
|
||||
return (
|
||||
f"{user_path} is a personal agent file system path, not a workspace path. "
|
||||
"Use user_files_read or user_files_copy_to_workspace for reads; use "
|
||||
"user_files_write for shared/tasks files or user_files_publish_output for outputs."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
|
||||
"""Resolve a user path and ensure the real target stays inside workspace."""
|
||||
|
||||
if error := _virtual_user_file_error(user_path):
|
||||
raise WorkspacePathError(error)
|
||||
root = _workspace_root(workspace)
|
||||
raw_path = Path(user_path or ".").expanduser()
|
||||
candidate = raw_path if raw_path.is_absolute() else root / raw_path
|
||||
@ -178,6 +198,8 @@ def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tupl
|
||||
|
||||
|
||||
def _resolve_writable_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
|
||||
if error := _virtual_user_file_error(user_path):
|
||||
raise WorkspacePathError(error)
|
||||
root = _workspace_root(workspace)
|
||||
if not user_path or not str(user_path).strip():
|
||||
raise WorkspacePathError("path is required")
|
||||
|
||||
389
app-instance/backend/beaver/tools/builtins/user_files.py
Normal file
389
app-instance/backend/beaver/tools/builtins/user_files.py
Normal file
@ -0,0 +1,389 @@
|
||||
"""Agent-facing tools for the user-visible file system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.config.loader import load_config
|
||||
from beaver.services.user_file_resolver import UserFileStorageResolver, build_file_auth_context
|
||||
from beaver.services.user_files import AgentUserFilePolicy, UserFileError, UserFilePathError, UserFileService
|
||||
|
||||
|
||||
MAX_WORKSPACE_STAGE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
def _json_result(success: bool, **payload: Any) -> str:
|
||||
return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def _service(workspace: str | None, services: dict[str, Any] | None = None) -> UserFileService:
|
||||
if not workspace:
|
||||
raise UserFileError("workspace is not configured for user file tools")
|
||||
config = (services or {}).get("beaver_config")
|
||||
if config is None:
|
||||
config = load_config(workspace=workspace)
|
||||
backend_id = config.backend_identity.backend_id.strip() or config.backend_identity.client_id.strip() or "agent"
|
||||
auth_context = build_file_auth_context(
|
||||
username=backend_id,
|
||||
config=config,
|
||||
user_id=(services or {}).get("user_id"),
|
||||
auth_source="beaver-agent-runtime",
|
||||
)
|
||||
return await UserFileStorageResolver(
|
||||
config=config,
|
||||
workspace=Path(workspace),
|
||||
auth_context=auth_context,
|
||||
).service()
|
||||
|
||||
|
||||
def _agent_policy(services: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> AgentUserFilePolicy:
|
||||
payload = services or {}
|
||||
meta = metadata or {}
|
||||
task_id = str(payload.get("task_id") or meta.get("task_id") or "").strip() or None
|
||||
fallback = str(payload.get("run_id") or meta.get("run_id") or meta.get("session_id") or "interactive")
|
||||
return AgentUserFilePolicy(task_id=task_id, fallback_scope=fallback)
|
||||
|
||||
|
||||
def _workspace_root(workspace: str | None) -> Path:
|
||||
if not workspace:
|
||||
raise UserFilePathError("workspace is not configured for user file tools")
|
||||
root = Path(workspace).expanduser().resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _resolve_workspace_source(workspace: str | None, source_path: str) -> tuple[Path, Path]:
|
||||
root = _workspace_root(workspace)
|
||||
if not source_path or not str(source_path).strip():
|
||||
raise UserFilePathError("source_path is required")
|
||||
raw = Path(str(source_path)).expanduser()
|
||||
candidate = raw if raw.is_absolute() else root / raw
|
||||
resolved = candidate.resolve(strict=True)
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise UserFilePathError("source_path escapes workspace") from exc
|
||||
if not resolved.is_file():
|
||||
raise UserFilePathError("source_path must be a file")
|
||||
return root, resolved
|
||||
|
||||
|
||||
def _resolve_workspace_destination(workspace: str | None, target_path: str) -> tuple[Path, Path]:
|
||||
root = _workspace_root(workspace)
|
||||
if not target_path or not str(target_path).strip():
|
||||
raise UserFilePathError("workspace_path is required")
|
||||
raw = Path(str(target_path)).expanduser()
|
||||
if raw.is_absolute():
|
||||
raise UserFilePathError("workspace_path must be relative")
|
||||
candidate = (root / raw).resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise UserFilePathError("workspace_path escapes workspace") from exc
|
||||
return root, candidate
|
||||
|
||||
|
||||
def _relative_path(root: Path, path: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
USER_FILES_LIST_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "User file path under uploads, outputs, shared, or tasks. Empty path lists the virtual roots.",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
USER_FILES_READ_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "User file path to read."},
|
||||
"max_bytes": {
|
||||
"type": "integer",
|
||||
"default": 120000,
|
||||
"minimum": 1,
|
||||
"maximum": 1000000,
|
||||
"description": "Maximum bytes to return in model context.",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
}
|
||||
|
||||
USER_FILES_WRITE_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "User file path to create or replace."},
|
||||
"content": {"type": "string", "description": "Text content to write."},
|
||||
"content_type": {"type": "string", "default": "text/plain"},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
}
|
||||
|
||||
USER_FILES_DELETE_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "User file or directory path to delete."}},
|
||||
"required": ["path"],
|
||||
}
|
||||
|
||||
USER_FILES_MKDIR_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "User file directory path to create."}},
|
||||
"required": ["path"],
|
||||
}
|
||||
|
||||
USER_FILES_COPY_TO_WORKSPACE_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Readable user file path under uploads, outputs, shared, or an authorized tasks namespace.",
|
||||
},
|
||||
"workspace_path": {
|
||||
"type": "string",
|
||||
"description": "Optional relative workspace destination. Defaults to user-files/tasks/{task_id}/<filename> or user-files/runs/<scope>/<filename>.",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
}
|
||||
|
||||
USER_FILES_PUBLISH_OUTPUT_PARAMETERS: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": "Workspace file path to publish. Absolute paths are allowed only if they stay inside the workspace.",
|
||||
},
|
||||
"target_path": {
|
||||
"type": "string",
|
||||
"description": "Output path under outputs/, such as outputs/report.md.",
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Optional content type. If omitted, Beaver guesses from the target filename.",
|
||||
},
|
||||
},
|
||||
"required": ["source_path", "target_path"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesListTool:
|
||||
name: str = "user_files_list"
|
||||
description: str = (
|
||||
"List files and folders in the personal agent file system. Use the virtual roots only: "
|
||||
"uploads for files the user provides to the agent, outputs for agent-generated results, "
|
||||
"shared for reusable user/agent reference material, and tasks for files bound to a specific task. "
|
||||
"An empty path lists the four roots; this tool never exposes MinIO buckets, credentials, or internal workspace paths."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = True
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_LIST_PARAMETERS))
|
||||
|
||||
async def execute(self, *, path: str = "", workspace: str | None = None, services: dict[str, Any] | None = None) -> str:
|
||||
try:
|
||||
return _json_result(True, **await (await _service(workspace, services)).browse(path))
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesReadTool:
|
||||
name: str = "user_files_read"
|
||||
description: str = (
|
||||
"Read a bounded text preview from the personal agent file system. Use this to inspect user-provided "
|
||||
"files in uploads, long-lived shared material in shared, task files in tasks, or generated outputs in outputs. "
|
||||
"The path must stay under uploads, outputs, shared, or tasks; internal workspace and MinIO implementation paths are hidden."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = True
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_READ_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
max_bytes: int = 120000,
|
||||
workspace: str | None = None,
|
||||
services: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
path = _agent_policy(services, metadata).validate_read(path)
|
||||
limit = max(1, min(int(max_bytes), 1_000_000))
|
||||
return _json_result(True, **await (await _service(workspace, services)).preview(path, max_bytes=limit))
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesWriteTool:
|
||||
name: str = "user_files_write"
|
||||
description: str = (
|
||||
"Create or replace a text file in the personal agent file system. Store agent-generated deliverables "
|
||||
"under outputs, reusable long-lived context under shared, and task-bound files under the current "
|
||||
"tasks/{task_id}/ namespace. Never write to uploads; uploaded files are immutable agent inputs. "
|
||||
"For modifications to uploaded files, copy them to the workspace, edit there, then publish to outputs."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_WRITE_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
content: str,
|
||||
content_type: str = "text/plain",
|
||||
workspace: str | None = None,
|
||||
services: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
path = _agent_policy(services, metadata).validate_write(path)
|
||||
return _json_result(True, **await (await _service(workspace, services)).write_file(path, content, content_type=content_type))
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesDeleteTool:
|
||||
name: str = "user_files_delete"
|
||||
description: str = (
|
||||
"Agent deletion is disabled for the personal agent file system. User-visible file deletion is owned by "
|
||||
"the Files page or user-side APIs; agents should use task/workspace cleanup instead."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_DELETE_PARAMETERS))
|
||||
|
||||
async def execute(self, *, path: str, workspace: str | None = None, services: dict[str, Any] | None = None) -> str:
|
||||
try:
|
||||
_agent_policy(services).validate_delete(path)
|
||||
return _json_result(False, path=path, deleted=False)
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesMkdirTool:
|
||||
name: str = "user_files_mkdir"
|
||||
description: str = (
|
||||
"Create a subfolder in the personal agent file system under uploads, outputs, shared, or tasks. "
|
||||
"Use folders to organize agent outputs, reusable shared material, or current task-specific files. "
|
||||
"Do not create folders under uploads because uploads is user-owned input storage."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_MKDIR_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
workspace: str | None = None,
|
||||
services: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
path = _agent_policy(services, metadata).validate_mkdir(path)
|
||||
return _json_result(True, **await (await _service(workspace, services)).mkdir(path))
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesCopyToWorkspaceTool:
|
||||
name: str = "user_files_copy_to_workspace"
|
||||
description: str = (
|
||||
"Copy a readable file from the personal agent file system into the internal workspace before editing, "
|
||||
"running, or validating it. Use this for user-uploaded files under uploads: the original upload remains "
|
||||
"unchanged, and the returned workspace_path can be used with workspace tools like read_file or patch_file."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_COPY_TO_WORKSPACE_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
workspace_path: str | None = None,
|
||||
workspace: str | None = None,
|
||||
services: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
policy = _agent_policy(services, metadata)
|
||||
path = policy.validate_read(path)
|
||||
content = await (await _service(workspace, services)).download(path)
|
||||
if content.size > MAX_WORKSPACE_STAGE_BYTES:
|
||||
raise UserFilePathError(f"File is too large to copy to workspace (max {MAX_WORKSPACE_STAGE_BYTES} bytes)")
|
||||
default_path = f"user-files/{policy.task_namespace}/{Path(path).name}"
|
||||
root, destination = _resolve_workspace_destination(workspace, workspace_path or default_path)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(content.content)
|
||||
return _json_result(
|
||||
True,
|
||||
path=path,
|
||||
workspace_path=_relative_path(root, destination),
|
||||
bytes=len(content.content),
|
||||
content_type=content.content_type,
|
||||
)
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
except OSError as exc:
|
||||
return _json_result(False, error=str(exc), path=path)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserFilesPublishOutputTool:
|
||||
name: str = "user_files_publish_output"
|
||||
description: str = (
|
||||
"Publish a validated workspace file to the personal agent file system under outputs/. Use this after "
|
||||
"staging and editing files in the workspace. Publishing never writes to uploads, and it hides MinIO "
|
||||
"bucket, namespace, and credential details from the agent."
|
||||
)
|
||||
toolset: str = "user_files"
|
||||
always_available: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_PUBLISH_OUTPUT_PARAMETERS))
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
content_type: str | None = None,
|
||||
workspace: str | None = None,
|
||||
services: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
root, source = _resolve_workspace_source(workspace, source_path)
|
||||
normalized_target = target_path.strip().strip("/")
|
||||
if not normalized_target.startswith("outputs/"):
|
||||
raise UserFilePathError("Published output target must be under outputs/")
|
||||
guessed_type, _ = mimetypes.guess_type(normalized_target)
|
||||
raw = source.read_bytes()
|
||||
entry = await (await _service(workspace, services)).write_file(
|
||||
normalized_target,
|
||||
raw,
|
||||
content_type=content_type or guessed_type or "application/octet-stream",
|
||||
)
|
||||
return _json_result(
|
||||
True,
|
||||
source_path=_relative_path(root, source),
|
||||
target_path=normalized_target,
|
||||
bytes=len(raw),
|
||||
**entry,
|
||||
)
|
||||
except UserFileError as exc:
|
||||
return _json_result(False, error=str(exc), source_path=source_path, target_path=target_path)
|
||||
except OSError as exc:
|
||||
return _json_result(False, error=str(exc), source_path=source_path, target_path=target_path)
|
||||
@ -51,7 +51,7 @@ class WebFetchTool:
|
||||
try:
|
||||
safe_url = _safe_url(url)
|
||||
limit = max(1000, min(int(max_chars or 12000), 50000))
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
|
||||
response = await client.get(
|
||||
safe_url,
|
||||
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
|
||||
@ -96,7 +96,7 @@ class WebSearchTool:
|
||||
raise ValueError("query is required")
|
||||
bounded = max(1, min(int(limit or 5), 10))
|
||||
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
|
||||
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
# User File System MinIO/AuthZ Setup
|
||||
|
||||
The user file system is exposed through Beaver APIs and `user_files_*` tools. MinIO remains an implementation detail.
|
||||
|
||||
The ordinary Files page should only call Beaver's `/api/user-files/*` routes and render the virtual roots `uploads/`, `outputs/`, `shared/`, and `tasks/`. It should not show bucket names, endpoint fields, access keys, secret keys, object prefixes, or MinIO administration actions.
|
||||
|
||||
## AuthZ Settings
|
||||
|
||||
Each backend identity can store MinIO settings in AuthZ:
|
||||
|
||||
```bash
|
||||
curl -X POST "$AUTHZ_URL/backends/$BACKEND_ID/settings/minio" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTHZ_ADMIN_TOKEN" \
|
||||
-d '{
|
||||
"endpoint": "minio.example.internal:9000",
|
||||
"access_key": "user-access-key",
|
||||
"secret_key": "user-secret-key",
|
||||
"bucket": "beaver-user-files",
|
||||
"namespace": "users/{backend_id}",
|
||||
"secure": false,
|
||||
"region": null
|
||||
}'
|
||||
```
|
||||
|
||||
Public reads return masked settings. Internal reads require `AUTHZ_INTERNAL_TOKEN` and return the secret key for protected MCP services.
|
||||
|
||||
Deployed personal files use a shared bucket with a backend-scoped namespace. For backend `alice`, Beaver maps:
|
||||
|
||||
- `uploads/report.pdf` to `users/alice/uploads/report.pdf`
|
||||
- `outputs/summary.md` to `users/alice/outputs/summary.md`
|
||||
- `tasks/task-123/result.json` to `users/alice/tasks/task-123/result.json`
|
||||
|
||||
The MinIO policy for Alice's access key must be limited to `beaver-user-files/users/alice/*`. The frontend must still only show Beaver virtual paths, not the shared bucket or namespace.
|
||||
|
||||
Check the public, masked view:
|
||||
|
||||
```bash
|
||||
curl "$AUTHZ_URL/backends/$BACKEND_ID/settings/minio" \
|
||||
-H "Authorization: Bearer $AUTHZ_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
Check the internal protected view used by MCP services:
|
||||
|
||||
```bash
|
||||
curl "$AUTHZ_URL/internal/backends/$BACKEND_ID/settings/minio" \
|
||||
-H "Authorization: Bearer $AUTHZ_INTERNAL_TOKEN"
|
||||
```
|
||||
|
||||
## Protected MinIO MCP
|
||||
|
||||
Run the MinIO MCP service in protected mode:
|
||||
|
||||
```bash
|
||||
bw-minio-mcp serve \
|
||||
--host 0.0.0.0 \
|
||||
--port 8001 \
|
||||
--authz-url "$AUTHZ_URL" \
|
||||
--authz-token "$AUTHZ_INTERNAL_TOKEN" \
|
||||
--resource-server-url "$MINIO_MCP_PUBLIC_URL/mcp" \
|
||||
--state-root /var/lib/bw-minio-mcp
|
||||
```
|
||||
|
||||
In protected mode, the MCP service does not use static MinIO credentials at startup. Each authenticated tool call resolves the backend identity from the bearer token, loads that backend's MinIO settings from AuthZ, and constructs a per-call provider.
|
||||
|
||||
Outside protected mode, `bw-minio-mcp serve` requires explicit `--endpoint`, `--access-key`, and `--secret-key` values. It intentionally has no embedded production fallback credentials.
|
||||
|
||||
## Beaver Runtime
|
||||
|
||||
Beaver should register the MinIO MCP endpoint with backend-token auth when raw object tools are needed:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"minio_mcp": {
|
||||
"url": "https://minio-mcp.example.internal/mcp",
|
||||
"auth": "oauth_backend_token",
|
||||
"authAudience": "mcp:minio_mcp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"authz": {
|
||||
"baseUrl": "https://authz.example.internal",
|
||||
"backendId": "backend-user-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Product-level file interactions should still go through Beaver's user file system:
|
||||
|
||||
- Frontend: `/api/user-files/status`, `/api/user-files/browse`, `/api/user-files/upload`, `/api/user-files/preview`, `/api/user-files/download`, `/api/user-files/delete`, and `/api/user-files/mkdir`.
|
||||
- Agent tools: `user_files_list`, `user_files_read`, `user_files_write`, `user_files_delete`, and `user_files_mkdir`.
|
||||
- Storage boundary: only `uploads/`, `outputs/`, `shared/`, and `tasks/` are valid user paths.
|
||||
|
||||
The local workspace browser APIs and generic filesystem tools are retained for runtime/development compatibility, but they are not the user-visible file boundary.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- The Files page root renders exactly `uploads`, `outputs`, `shared`, and `tasks`.
|
||||
- The Files page source does not call `/api/workspace/browse`.
|
||||
- `/api/user-files/status` does not return local workspace paths or MinIO bucket details.
|
||||
- AuthZ public settings responses mask `secret_key`.
|
||||
- Protected `BW_MinIO_Mcp` returns a clear configuration error if a backend has no MinIO settings instead of falling back to another user's credentials.
|
||||
@ -0,0 +1,12 @@
|
||||
# User File System Tooling Boundary
|
||||
|
||||
The `personal-user-filesystem` change adds `user_files_*` tools for files that users can upload, inspect, and receive from agents. These tools enforce the same virtual roots as the web API:
|
||||
|
||||
- `uploads/`
|
||||
- `outputs/`
|
||||
- `shared/`
|
||||
- `tasks/`
|
||||
|
||||
The existing local workspace filesystem tools remain registered for internal runtime and development workflows. They are workspace-scoped, but they are not the user-visible file boundary. Agents should use `user_files_*` tools when reading user-provided files or writing user-facing outputs.
|
||||
|
||||
Follow-up for stronger isolation: add a runtime policy switch that disables or narrows local workspace filesystem tools for ordinary personal-agent tasks, while keeping `user_files_*` available.
|
||||
@ -11,6 +11,7 @@ dependencies = [
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"json-repair>=0.39.0,<1.0.0",
|
||||
"litellm>=1.79.0,<2.0.0",
|
||||
"minio>=7.2.0,<8.0.0",
|
||||
"openai>=1.79.0,<2.0.0",
|
||||
"pydantic>=2.12.0,<3.0.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
|
||||
|
||||
|
||||
def _run_result(run_id: str, output_text: str) -> AgentRunResult:
|
||||
return AgentRunResult(
|
||||
session_id="web:test",
|
||||
run_id=run_id,
|
||||
output_text=output_text,
|
||||
finish_reason="stop",
|
||||
tool_iterations=0,
|
||||
)
|
||||
|
||||
|
||||
def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
|
||||
async def run_case() -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_process_direct(task: str, **kwargs: Any) -> AgentRunResult:
|
||||
calls.append(task)
|
||||
if task == "outer":
|
||||
return await loop.submit_direct("inner", session_id="web:test")
|
||||
return _run_result(task, "inner completed")
|
||||
|
||||
loop._process_direct_impl = fake_process_direct # type: ignore[method-assign]
|
||||
|
||||
loop_task = asyncio.create_task(loop.run())
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
result = await asyncio.wait_for(loop.submit_direct("outer", session_id="web:test"), timeout=1)
|
||||
finally:
|
||||
await loop.stop()
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(loop_task, timeout=1)
|
||||
if not loop_task.done():
|
||||
loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await loop_task
|
||||
|
||||
assert result.output_text == "inner completed"
|
||||
assert calls == ["outer", "inner"]
|
||||
|
||||
asyncio.run(run_case())
|
||||
@ -45,18 +45,6 @@ class RecordingProvider(LLMProvider):
|
||||
return "stub-model"
|
||||
|
||||
|
||||
class BlockingProvider(RecordingProvider):
|
||||
def __init__(self, content: str, started: asyncio.Event, release: asyncio.Event) -> None:
|
||||
super().__init__([_response(content)])
|
||||
self.started = started
|
||||
self.release = release
|
||||
|
||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||
self.started.set()
|
||||
await self.release.wait()
|
||||
return await super().chat(*args, **kwargs)
|
||||
|
||||
|
||||
class StubSkillAssembler:
|
||||
def __init__(self, activated_skills: list[SkillContext] | None = None) -> None:
|
||||
self.activated_skills = list(activated_skills or [])
|
||||
@ -165,26 +153,6 @@ def test_local_agent_runner_uses_shared_loop_and_records_parent_task(tmp_path: P
|
||||
assert child_session["parent_session_id"] == "session-root"
|
||||
|
||||
|
||||
def test_team_node_preserves_evidence_when_finish_reason_is_not_stop(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
provider = RecordingProvider([_response("partial evidence", finish_reason="max_tool_iterations")])
|
||||
envelope = DelegationEnvelope(
|
||||
parent_task_id="task-parent",
|
||||
parent_session_id="session-root",
|
||||
parent_run_id="run-root",
|
||||
agent=AgentDescriptor(name="researcher", role="research"),
|
||||
task="research the requested topic",
|
||||
node_id="research",
|
||||
)
|
||||
|
||||
result = asyncio.run(LocalAgentRunner(loop).run(envelope, provider_bundle=_bundle(provider)))
|
||||
|
||||
assert result.success is False
|
||||
assert result.evidence is not None
|
||||
assert result.evidence.output_text == "partial evidence"
|
||||
assert result.evidence.finish_reason == "max_tool_iterations"
|
||||
|
||||
|
||||
def test_pinned_skill_is_injected_into_delegated_run(tmp_path: Path) -> None:
|
||||
_publish_skill(
|
||||
tmp_path,
|
||||
@ -310,57 +278,6 @@ def test_team_parallel_runs_all_nodes(tmp_path: Path) -> None:
|
||||
assert [item.output_text for item in result.node_results] == ["one", "two", "three"]
|
||||
|
||||
|
||||
def test_team_parallel_starts_nodes_concurrently_with_isolated_loops(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
first_started = asyncio.Event()
|
||||
second_started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
providers = {
|
||||
"one": BlockingProvider("one", first_started, release),
|
||||
"two": BlockingProvider("two", second_started, release),
|
||||
}
|
||||
graph = ExecutionGraph(
|
||||
strategy="parallel",
|
||||
nodes=[
|
||||
ExecutionNode("one", "task one", AgentDescriptor(name="one")),
|
||||
ExecutionNode("two", "task two", AgentDescriptor(name="two")),
|
||||
],
|
||||
)
|
||||
|
||||
async def run_case():
|
||||
loop_task = asyncio.create_task(loop.run())
|
||||
await asyncio.sleep(0)
|
||||
task = asyncio.create_task(
|
||||
TeamService(loop).run_team(
|
||||
graph,
|
||||
parent_task_id=None,
|
||||
parent_session_id="session-root",
|
||||
parent_run_id="run-root",
|
||||
provider_bundle_factory=lambda node: _bundle(providers[node.node_id]),
|
||||
)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(first_started.wait(), timeout=1)
|
||||
await asyncio.wait_for(second_started.wait(), timeout=1)
|
||||
release.set()
|
||||
return await task
|
||||
finally:
|
||||
release.set()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await loop.stop()
|
||||
await loop_task
|
||||
|
||||
result = asyncio.run(run_case())
|
||||
|
||||
assert result.success is True
|
||||
assert [item.node_id for item in result.node_results] == ["one", "two"]
|
||||
|
||||
|
||||
def test_parallel_node_factory_error_is_normalized_and_keeps_completed_runs(tmp_path: Path) -> None:
|
||||
loop = _loop(tmp_path)
|
||||
loaded = loop.boot()
|
||||
@ -521,7 +438,7 @@ def test_team_summary_lists_only_failed_nodes_when_all_nodes_fail(tmp_path: Path
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.summary == "Failed nodes:\n- one: one down evidence=no\n- two: two down evidence=no"
|
||||
assert result.summary == "Failed nodes:\n- one: one down\n- two: two down"
|
||||
|
||||
|
||||
def test_graph_structure_errors_still_raise(tmp_path: Path) -> None:
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
from beaver.foundation.config import load_config
|
||||
from beaver.interfaces.web.app import create_app, _reload_agent_config
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
||||
@ -128,123 +124,6 @@ def test_agent_loop_config_drives_provider_bundle(tmp_path) -> None:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
|
||||
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
loaded = service.create_loop().boot()
|
||||
assert loaded.config.default_model == "old-model"
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
|
||||
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_reload_agent_config(service, config_path)
|
||||
|
||||
target = service.create_loop().boot().config.resolve_provider_target()
|
||||
assert target["model"] == "new-model"
|
||||
assert target["api_base"] == "https://new.example.com/v1"
|
||||
assert target["api_key"] == "sk-test"
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"maxTokens": 12345,
|
||||
"temperature": 0.4,
|
||||
"maxToolIterations": 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
service = AgentService(config_path=config_path)
|
||||
|
||||
assert config.agents_defaults.max_tokens == 12345
|
||||
assert config.agents_defaults.temperature == 0.4
|
||||
assert config.agents_defaults.max_tool_iterations == 9
|
||||
assert service.profile.max_tokens == 12345
|
||||
assert service.profile.temperature == 0.4
|
||||
assert service.profile.max_tool_iterations == 9
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": 8192, "temperature": 0.6, "max_tool_iterations": 12},
|
||||
)
|
||||
status = client.get("/api/status")
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
defaults = saved["agents"]["defaults"]
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert defaults["maxTokens"] == 8192
|
||||
assert defaults["temperature"] == 0.6
|
||||
assert defaults["maxToolIterations"] == 12
|
||||
assert service.profile.max_tokens == 8192
|
||||
assert service.profile.temperature == 0.6
|
||||
assert service.profile.max_tool_iterations == 12
|
||||
assert status.json()["max_tokens"] == 8192
|
||||
assert status.json()["temperature"] == 0.6
|
||||
assert status.json()["max_tool_iterations"] == 12
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": None, "temperature": 0, "max_tool_iterations": 0},
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert config.agents_defaults.max_tokens is None
|
||||
assert config.agents_defaults.temperature == 0
|
||||
assert config.agents_defaults.max_tool_iterations == 0
|
||||
assert service.profile.max_tokens is None
|
||||
assert service.profile.temperature == 0
|
||||
assert service.profile.max_tool_iterations == 0
|
||||
service.close()
|
||||
|
||||
|
||||
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||
bundle = make_provider_bundle(
|
||||
model="qwen-plus",
|
||||
@ -320,4 +199,5 @@ def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
|
||||
assert local.kind == "local"
|
||||
assert local.category == "filesystem"
|
||||
assert local.managed is True
|
||||
assert local.display_name == "个人智能体文件系统工具"
|
||||
assert "beaver.interfaces.mcp.tools_server" in local.args
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver.engine.context import ContextBuildInput, ContextBuilder, RuntimeContext, SessionContext
|
||||
|
||||
|
||||
def test_context_builder_injects_current_date_and_time() -> None:
|
||||
result = ContextBuilder().build_messages(
|
||||
ContextBuildInput(
|
||||
base_system_prompt="Follow user requests.",
|
||||
current_user_input="今天几号?",
|
||||
session_context=SessionContext(session_id="web:alpha", source="web", model="stub-model"),
|
||||
runtime_context=RuntimeContext(
|
||||
utc_datetime="2026-05-26T01:10:00+00:00",
|
||||
local_datetime="2026-05-26T09:10:00+08:00",
|
||||
timezone="Asia/Shanghai",
|
||||
utc_offset="+08:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
system_prompt = result.messages[0]["content"]
|
||||
assert "# Current Date and Time" in system_prompt
|
||||
assert "Current UTC time: 2026-05-26T01:10:00+00:00" in system_prompt
|
||||
assert "Current local time: 2026-05-26T09:10:00+08:00" in system_prompt
|
||||
assert "Local timezone: Asia/Shanghai" in system_prompt
|
||||
assert "Local UTC offset: +08:00" in system_prompt
|
||||
assert '"today", "tomorrow", "now", "this week", and "next month"' in system_prompt
|
||||
assert result.messages[-1] == {"role": "user", "content": "今天几号?"}
|
||||
@ -6,7 +6,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.tools import ObjectBackedTool, ToolContext
|
||||
from beaver.tools.builtins import ListDirectoryTool, ReadFileTool, SearchFilesTool
|
||||
from beaver.tools.builtins import ListDirectoryTool, PatchFileTool, ReadFileTool, SearchFilesTool, WriteFileTool
|
||||
|
||||
|
||||
def _run_tool(tool, arguments: dict, workspace: Path):
|
||||
@ -127,3 +127,23 @@ def test_read_file_rejects_binary_files(tmp_path: Path) -> None:
|
||||
assert payload["success"] is False
|
||||
assert "binary" in payload["error"]
|
||||
|
||||
|
||||
def test_workspace_tools_reject_user_file_virtual_paths(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
|
||||
read = _run_tool(ReadFileTool(), {"path": "uploads/get_helm.sh"}, workspace)
|
||||
listed = _run_tool(ListDirectoryTool(), {"path": "outputs"}, workspace)
|
||||
written = _run_tool(WriteFileTool(), {"path": "shared/profile.json", "content": "{}"}, workspace)
|
||||
patched = _run_tool(
|
||||
PatchFileTool(),
|
||||
{"path": "tasks/task-123/draft.md", "old_text": "a", "new_text": "b"},
|
||||
workspace,
|
||||
)
|
||||
|
||||
for result in (read, listed, written, patched):
|
||||
payload = _payload(result)
|
||||
assert result.success is False
|
||||
assert payload["success"] is False
|
||||
assert "personal agent file system path" in payload["error"]
|
||||
assert "user_files_read" in payload["error"]
|
||||
|
||||
@ -18,8 +18,8 @@ class FakeResult:
|
||||
model: str | None = "fake-model"
|
||||
usage: dict[str, Any] = field(default_factory=dict)
|
||||
task_id: str | None = "task-1"
|
||||
task_status: str | None = "awaiting_acceptance"
|
||||
validation_result: dict[str, Any] | None = None
|
||||
task_status: str | None = "awaiting_feedback"
|
||||
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
||||
|
||||
|
||||
class FakeService:
|
||||
@ -79,9 +79,8 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
|
||||
assert message.session_id == "s1"
|
||||
assert message.finish_reason == "stop"
|
||||
assert message.metadata["task_id"] == "task-1"
|
||||
assert message.metadata["task_status"] == "awaiting_acceptance"
|
||||
assert message.metadata["evidence_status"] == "recorded"
|
||||
assert message.metadata["validation_result"] is None
|
||||
assert message.metadata["task_status"] == "awaiting_feedback"
|
||||
assert message.metadata["validation_result"] == {"accepted": True}
|
||||
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.engine import EngineLoader
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
EXPECTED_INITIAL_SKILL_TOOLS = {
|
||||
"cron-scheduler": ["cron"],
|
||||
"filesystem-operation": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
|
||||
"memory-management": ["memory"],
|
||||
"outlook-mail": [
|
||||
"mcp_outlook_mcp_mail_list_folders",
|
||||
"mcp_outlook_mcp_mail_list_messages",
|
||||
"mcp_outlook_mcp_mail_search_messages",
|
||||
"mcp_outlook_mcp_mail_get_message",
|
||||
"mcp_outlook_mcp_mail_send_email",
|
||||
"mcp_outlook_mcp_mail_reply_to_message",
|
||||
"mcp_outlook_mcp_mail_forward_message",
|
||||
"mcp_outlook_mcp_mail_move_message",
|
||||
"mcp_outlook_mcp_mail_delta_sync",
|
||||
"mcp_outlook_mcp_calendar_list_events",
|
||||
"mcp_outlook_mcp_calendar_create_event",
|
||||
"mcp_outlook_mcp_calendar_update_event",
|
||||
"mcp_outlook_mcp_calendar_get_schedule",
|
||||
"mcp_outlook_mcp_calendar_find_meeting_times",
|
||||
"mcp_outlook_mcp_calendar_delta_sync",
|
||||
],
|
||||
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
|
||||
"terminal-operation": ["terminal", "process", "execute_code"],
|
||||
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
}
|
||||
|
||||
|
||||
def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
|
||||
for skill_name, expected_tools in EXPECTED_INITIAL_SKILL_TOOLS.items():
|
||||
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
|
||||
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
|
||||
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert frontmatter["tools"] == expected_tools
|
||||
assert version["frontmatter"]["tools"] == expected_tools
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
|
||||
loaded = EngineLoader(workspace=tmp_path).load()
|
||||
try:
|
||||
assert "skill_view" in loaded.tools
|
||||
assert loaded.tool_registry is not None
|
||||
assert loaded.tool_registry.get("skill_view") is not None
|
||||
finally:
|
||||
loaded.close()
|
||||
@ -45,13 +45,10 @@ def test_qwen_thinking_mode_is_sent_as_chat_template_kwargs(monkeypatch: pytest.
|
||||
)
|
||||
|
||||
assert response.content == "可以"
|
||||
assert captured["extra_body"] == {
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"thinking": {"type": "disabled"},
|
||||
}
|
||||
assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def test_thinking_mode_disabled_is_sent_without_model_name_matching(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_non_qwen_thinking_mode_is_not_sent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
@ -88,85 +85,7 @@ def test_thinking_mode_disabled_is_sent_without_model_name_matching(monkeypatch:
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["extra_body"] == {
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"thinking": {"type": "disabled"},
|
||||
}
|
||||
|
||||
|
||||
def test_litellm_provider_preserves_reasoning_content_for_tool_round_trip() -> None:
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "must be passed back",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
assert LiteLLMProvider._sanitize_messages(messages)[0]["reasoning_content"] == "must be passed back"
|
||||
|
||||
|
||||
def test_litellm_provider_merges_late_system_messages_to_front() -> None:
|
||||
messages = [
|
||||
{"role": "system", "content": "base"},
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "system", "content": "finalize without tools"},
|
||||
]
|
||||
|
||||
sanitized = LiteLLMProvider._sanitize_messages(messages)
|
||||
|
||||
assert [message["role"] for message in sanitized] == ["system", "user"]
|
||||
assert sanitized[0]["content"] == "base\n\nfinalize without tools"
|
||||
|
||||
|
||||
def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="sk-test",
|
||||
api_base="https://oai.example.com/v1",
|
||||
default_model="gpt-4.1-mini",
|
||||
provider_name="openai",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "reply ok"}],
|
||||
model="gpt-4.1-mini",
|
||||
thinking_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["extra_body"] == {
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"thinking": {"type": "disabled"},
|
||||
}
|
||||
assert "extra_body" not in captured
|
||||
|
||||
|
||||
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@ -79,7 +79,7 @@ def _task() -> TaskRecord:
|
||||
goal="实现任务连续性",
|
||||
constraints=[],
|
||||
priority=0,
|
||||
status="awaiting_acceptance",
|
||||
status="awaiting_feedback",
|
||||
creator="test",
|
||||
created_at="now",
|
||||
updated_at="now",
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.loop import AgentProfile
|
||||
from beaver.engine.providers.anthropic import AnthropicProvider
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
|
||||
|
||||
def test_agent_profile_uses_provider_output_default() -> None:
|
||||
assert AgentProfile().max_tokens is None
|
||||
|
||||
|
||||
def test_litellm_omits_max_tokens_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content="ok", tool_calls=[]),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
|
||||
async def run_case():
|
||||
provider = LiteLLMProvider(default_model="openai/gpt-test")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert "max_tokens" not in captured_kwargs
|
||||
|
||||
|
||||
def test_anthropic_uses_model_output_ceiling_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
class FakeMessages:
|
||||
async def create(self, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
usage=None,
|
||||
stop_reason="stop",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
messages = FakeMessages()
|
||||
|
||||
monkeypatch.setattr(AnthropicProvider, "_client_or_raise", lambda self: FakeClient())
|
||||
|
||||
async def run_case():
|
||||
provider = AnthropicProvider(default_model="claude-sonnet-4-5")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert captured_kwargs["max_tokens"] == 64_000
|
||||
22
app-instance/backend/tests/unit/test_mcp_tools_server.py
Normal file
22
app-instance/backend/tests/unit/test_mcp_tools_server.py
Normal file
@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver.interfaces.mcp.tools_server import _category_tools
|
||||
|
||||
|
||||
def test_local_filesystem_mcp_exposes_personal_user_file_tools_only(tmp_path) -> None:
|
||||
tools, _context = _category_tools("filesystem", tmp_path)
|
||||
|
||||
names = [tool.spec.name for tool in tools]
|
||||
|
||||
assert names == [
|
||||
"user_files_list",
|
||||
"user_files_read",
|
||||
"user_files_write",
|
||||
"user_files_mkdir",
|
||||
"user_files_copy_to_workspace",
|
||||
"user_files_publish_output",
|
||||
]
|
||||
assert "read_file" not in names
|
||||
assert "search_files" not in names
|
||||
assert "list_directory" not in names
|
||||
assert all("personal agent file system" in tool.spec.description for tool in tools)
|
||||
@ -35,7 +35,6 @@ class StubProvider(LLMProvider):
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
if not self._responses:
|
||||
raise AssertionError("No stubbed provider responses left")
|
||||
@ -48,22 +47,11 @@ class StubProvider(LLMProvider):
|
||||
class StubSkillAssembler:
|
||||
def __init__(self, activated_skills: list[SkillContext]) -> None:
|
||||
self.activated_skills = activated_skills
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||
self.calls.append(kwargs)
|
||||
return SkillAssemblyResult(activated_skills=list(self.activated_skills))
|
||||
|
||||
|
||||
class RecordingToolAssembler:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def assemble(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return kwargs["registry"].get_specs(["memory"])
|
||||
|
||||
|
||||
def _tool_call(*, name: str = "echo", arguments: dict | None = None, call_id: str = "call-1") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=call_id,
|
||||
@ -588,48 +576,6 @@ def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
|
||||
assert effect_records[-1].run_id == result.run_id
|
||||
|
||||
|
||||
def test_thinking_disabled_still_uses_skill_and_tool_assembly(tmp_path: Path) -> None:
|
||||
skill = SkillContext(
|
||||
name="docker-debug",
|
||||
content="Use docker logs before editing config.",
|
||||
version="v0007",
|
||||
content_hash="hash-v7",
|
||||
activation_reason="llm_selected",
|
||||
tool_hints=["terminal"],
|
||||
)
|
||||
skill_assembler = StubSkillAssembler([skill])
|
||||
tool_assembler = RecordingToolAssembler()
|
||||
loader = EngineLoader(
|
||||
workspace=tmp_path,
|
||||
skill_assembler=skill_assembler,
|
||||
tool_assembler=tool_assembler,
|
||||
)
|
||||
loop = AgentLoop(loader=loader)
|
||||
bundle = ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[LLMResponse(content="Done", finish_reason="stop", provider_name="stub", model="stub-model")]
|
||||
),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
loop.process_direct(
|
||||
"Why is the Docker container crashing?",
|
||||
provider_bundle=bundle,
|
||||
thinking_enabled=False,
|
||||
)
|
||||
)
|
||||
loaded = loop.boot()
|
||||
events = loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
|
||||
tool_selection = next(event for event in events if event.event_type == "tool_selection_snapshotted")
|
||||
|
||||
assert skill_assembler.calls
|
||||
assert skill_assembler.calls[0]["thinking_enabled"] is False
|
||||
assert tool_assembler.calls
|
||||
assert [skill.name for skill in tool_assembler.calls[0]["activated_skills"]] == ["docker-debug"]
|
||||
assert tool_selection.event_payload["tool_names"] == ["memory"]
|
||||
|
||||
|
||||
def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path: Path) -> None:
|
||||
skill = SkillContext(
|
||||
name="docker-debug",
|
||||
@ -662,12 +608,6 @@ def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path:
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Based on the available tool result, the container likely failed during startup.",
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
@ -681,75 +621,7 @@ def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path:
|
||||
)
|
||||
loaded = loop.boot()
|
||||
|
||||
assert result.finish_reason == "max_tool_iterations_finalized"
|
||||
assert "Based on the available tool result" in result.output_text
|
||||
assert "Tool loop stopped" not in result.output_text
|
||||
assert result.finish_reason == "max_tool_iterations"
|
||||
effect_records = loaded.run_memory_store.list_skill_effects("docker-debug", version="v0007")
|
||||
assert effect_records[-1].run_id == result.run_id
|
||||
assert effect_records[-1].success is False
|
||||
|
||||
|
||||
def test_agent_loop_suppresses_raw_tool_call_when_finalizing_after_tool_limit(tmp_path: Path) -> None:
|
||||
loader = EngineLoader(
|
||||
workspace=tmp_path,
|
||||
skill_assembler=StubSkillAssembler([]),
|
||||
)
|
||||
loop = AgentLoop(loader=loader)
|
||||
bundle = ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[
|
||||
LLMResponse(
|
||||
content="Need a tool.",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[_tool_call()],
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
),
|
||||
LLMResponse(
|
||||
content=(
|
||||
"<tool_call>\n"
|
||||
"<function=mcp_local_web_mcp_web_fetch>\n"
|
||||
"<parameter=url>https://example.com</parameter>\n"
|
||||
"</function>\n"
|
||||
"</tool_call>"
|
||||
),
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
loop.process_direct(
|
||||
"Fetch the latest result",
|
||||
provider_bundle=bundle,
|
||||
max_tool_iterations=0,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.finish_reason == "max_tool_iterations"
|
||||
assert "<tool_call>" not in result.output_text
|
||||
assert "raw tool call was suppressed" in result.output_text
|
||||
|
||||
|
||||
def test_llm_request_snapshot_defaults_to_compact_payload(tmp_path: Path) -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path, skill_assembler=StubSkillAssembler([])))
|
||||
bundle = ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[LLMResponse(content="done", finish_reason="stop", provider_name="stub", model="stub-model")]
|
||||
),
|
||||
)
|
||||
|
||||
result = asyncio.run(loop.process_direct("hello", provider_bundle=bundle))
|
||||
loaded = loop.boot()
|
||||
events = loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
|
||||
snapshot = next(event for event in events if event.event_type == "llm_request_snapshotted")
|
||||
|
||||
assert "message_count" in snapshot.event_payload
|
||||
assert "tool_names" in snapshot.event_payload
|
||||
assert "messages" not in snapshot.event_payload
|
||||
assert "tools" not in snapshot.event_payload
|
||||
|
||||
@ -5,7 +5,6 @@ from pathlib import Path
|
||||
from beaver.engine.session import SessionManager
|
||||
from beaver.memory.runs import RunMemoryStore, RunRecord
|
||||
from beaver.services.process_service import SessionProcessProjector
|
||||
from beaver.skills.specs import SkillActivationReceipt
|
||||
|
||||
|
||||
def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
@ -102,23 +101,12 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="system",
|
||||
event_type="task_evidence_recorded",
|
||||
event_type="task_validation_snapshotted",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"evidence_status": "recorded",
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="system",
|
||||
event_type="task_acceptance_recorded",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"acceptance_type": "accept",
|
||||
"validation_result": {"accepted": True, "score": 0.9},
|
||||
"retry_scheduled": False,
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
@ -133,235 +121,9 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"]
|
||||
assert sub_run["metadata"]["skill_query"] == "research workflow"
|
||||
assert sub_run["metadata"]["ephemeral_guidance_id"] is None
|
||||
assert any(event["actor_name"] == "Evidence" for event in projection["events"])
|
||||
assert any(event["actor_name"] == "Validator" for event in projection["events"])
|
||||
assert any(run["session_id"] == "web:test" for run in projection["runs"])
|
||||
|
||||
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
|
||||
assert planned_event["metadata"]["timeline_type"] == "plan"
|
||||
assert planned_event["metadata"]["plan_mode"] == "team"
|
||||
assert planned_event["metadata"]["strategy"] == "sequence"
|
||||
assert planned_event["metadata"]["selected_skill_names"] == ["research-workflow"]
|
||||
|
||||
skill_event = next(event for event in projection["events"] if event["kind"] == "skill_selected")
|
||||
assert skill_event["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_event["metadata"]["skill_names"] == ["research-workflow"]
|
||||
|
||||
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
assert team_event["metadata"]["team_run_ids"] == ["sub-run"]
|
||||
|
||||
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
|
||||
assert node_event["metadata"]["timeline_type"] == "agent_progress"
|
||||
assert "node_result" not in node_event["metadata"]
|
||||
|
||||
evidence_event = next(event for event in projection["events"] if event["kind"] == "task_result_ready")
|
||||
assert evidence_event["metadata"]["timeline_type"] == "result"
|
||||
assert evidence_event["status"] == "done"
|
||||
|
||||
acceptance_event = next(event for event in projection["events"] if event["kind"] == "task_acceptance_recorded")
|
||||
assert acceptance_event["metadata"]["timeline_type"] == "acceptance"
|
||||
|
||||
|
||||
def test_process_projection_maps_failed_task_team_events(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="failed-sub-run",
|
||||
session_id="failed-sub-session",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="failed sub task",
|
||||
started_at="2026-01-01T00:00:01+00:00",
|
||||
ended_at="2026-01-01T00:00:02+00:00",
|
||||
success=False,
|
||||
finish_reason="error",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_team_run_failed",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"team_success": False,
|
||||
"team_run_ids": ["failed-sub-run"],
|
||||
"error": "research node failed",
|
||||
"node_results": [
|
||||
{
|
||||
"node_id": "research",
|
||||
"success": False,
|
||||
"error": "source unavailable",
|
||||
"run_id": "failed-sub-run",
|
||||
"finish_reason": "error",
|
||||
}
|
||||
],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
|
||||
assert team_event["status"] == "error"
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
assert team_event["metadata"]["team_run_ids"] == ["failed-sub-run"]
|
||||
|
||||
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
|
||||
assert node_event["status"] == "error"
|
||||
assert node_event["metadata"]["timeline_type"] == "agent_progress"
|
||||
|
||||
|
||||
def test_process_projection_uses_normalized_plan_metadata_defaults(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": None,
|
||||
"strategy": None,
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
root_run = next(run for run in projection["runs"] if run["run_id"] == "task:task-1:attempt:1")
|
||||
assert root_run["metadata"]["plan_mode"] == "single"
|
||||
assert root_run["metadata"]["strategy"] == "single"
|
||||
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
|
||||
assert planned_event["metadata"]["plan_mode"] == "single"
|
||||
assert planned_event["metadata"]["strategy"] == "single"
|
||||
|
||||
|
||||
def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
activated_skills=[
|
||||
SkillActivationReceipt(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
skill_name="web-operation",
|
||||
skill_version="1",
|
||||
content_hash="hash",
|
||||
activated_at="2026-01-01T00:00:03+00:00",
|
||||
activation_reason="Needs live web lookup.",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": "single",
|
||||
"strategy": "single",
|
||||
"selected_skill_names": [],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_synthesis_completed",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1, "main_run_id": "main-run"},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
skill_events = [
|
||||
event
|
||||
for event in projection["events"]
|
||||
if event["kind"] == "skill_selected" and event["run_id"] == "main-run"
|
||||
]
|
||||
assert skill_events
|
||||
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
|
||||
|
||||
|
||||
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="assistant",
|
||||
event_type="assistant_message_added",
|
||||
event_payload={"task_id": "task-1"},
|
||||
content="Searching",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"name": "multi_search",
|
||||
"arguments": {"query": "Macau cafe near Bóvia"},
|
||||
}
|
||||
],
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="tool",
|
||||
event_type="tool_result_recorded",
|
||||
event_payload={"success": True, "error": None},
|
||||
content="Found 3 restaurants",
|
||||
tool_name="multi_search",
|
||||
tool_call_id="call-1",
|
||||
context_visible=True,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
tool_call = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
|
||||
assert tool_call["metadata"]["timeline_type"] == "tool_call"
|
||||
assert tool_call["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_call["run_id"] == "main-run"
|
||||
|
||||
tool_result = next(event for event in projection["events"] if event["kind"] == "tool_call_finished")
|
||||
assert tool_result["metadata"]["timeline_type"] == "tool_result"
|
||||
assert tool_result["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_result["metadata"]["success"] is True
|
||||
|
||||
|
||||
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.engine.session.manager import SessionManager
|
||||
from beaver.tasks.evidence import EvidenceBuilder, RunEvidence, TaskEvidencePacket, ToolEvidence, render_task_evidence
|
||||
|
||||
|
||||
def test_evidence_builder_preserves_full_tool_result(tmp_path: Path) -> None:
|
||||
session_manager = SessionManager(tmp_path)
|
||||
session_id = "session-1"
|
||||
run_id = "run-1"
|
||||
long_content = "prefix " + ("x" * 700) + " MAN 3 FT 2 NFO"
|
||||
session_manager.ensure_session(session_id, source="test")
|
||||
session_manager.append_message(session_id, run_id=run_id, role="user", event_type="user_message_added", content="score?")
|
||||
session_manager.append_message(
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="tool",
|
||||
event_type="tool_result_recorded",
|
||||
event_payload={"success": True, "url": "https://example.test/match"},
|
||||
content=long_content,
|
||||
tool_name="web_fetch",
|
||||
tool_call_id="call-1",
|
||||
)
|
||||
session_manager.append_message(
|
||||
session_id,
|
||||
run_id=run_id,
|
||||
role="system",
|
||||
event_type="run_completed",
|
||||
event_payload={"finish_reason": "stop"},
|
||||
content="Manchester United won 3-2.",
|
||||
finish_reason="stop",
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
evidence = EvidenceBuilder(session_manager).build_run_evidence(
|
||||
session_id,
|
||||
run_id,
|
||||
"Manchester United won 3-2.",
|
||||
"stop",
|
||||
)
|
||||
rendered = render_task_evidence(
|
||||
TaskEvidencePacket(
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
main_run=evidence,
|
||||
team_runs=[],
|
||||
team_node_results=[],
|
||||
final_output="Manchester United won 3-2.",
|
||||
)
|
||||
)
|
||||
|
||||
assert evidence.tool_results[0].content == long_content
|
||||
assert "MAN 3 FT 2 NFO" in rendered
|
||||
assert "https://example.test/match" in rendered
|
||||
|
||||
|
||||
def test_render_task_evidence_includes_failed_team_run_tool_results() -> None:
|
||||
run = RunEvidence(
|
||||
run_id="run-team",
|
||||
session_id="session-team",
|
||||
output_text="Tool loop stopped.",
|
||||
finish_reason="max_tool_iterations",
|
||||
transcript=[],
|
||||
tool_results=[
|
||||
ToolEvidence(
|
||||
tool_name="web_fetch",
|
||||
tool_call_id="call-team",
|
||||
content="Recovered partial source content.",
|
||||
event_payload={"success": True, "created_at": "2026-05-22T12:00:00Z"},
|
||||
created_at="2026-05-22T12:00:00Z",
|
||||
)
|
||||
],
|
||||
warnings=["finish_reason=max_tool_iterations"],
|
||||
)
|
||||
packet = TaskEvidencePacket(
|
||||
task_id="task-1",
|
||||
attempt_index=2,
|
||||
main_run=None,
|
||||
team_runs=[run],
|
||||
team_node_results=[],
|
||||
final_output="partial answer",
|
||||
)
|
||||
|
||||
rendered = render_task_evidence(packet)
|
||||
|
||||
assert "finish_reason=max_tool_iterations" in rendered
|
||||
assert "partial answer" in rendered
|
||||
assert "Recovered partial source content." in rendered
|
||||
assert "created_at=2026-05-22T12:00:00Z" in rendered
|
||||
@ -4,17 +4,23 @@ import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver.coordinator import AgentDescriptor, ExecutionGraph, ExecutionNode
|
||||
from beaver.engine import EngineLoader
|
||||
from beaver.engine.context.builder import ContextBuilder, ContextBuildInput
|
||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||
from beaver.engine.providers.factory import ProviderBundle
|
||||
from beaver.services.agent_service import AgentService
|
||||
from beaver.tasks import TaskExecutionPlan, TaskService
|
||||
from beaver.skills.assembler import SkillAssemblyResult
|
||||
from beaver.tasks import TaskExecutionPlan, TaskService, ValidationResult, ValidationService
|
||||
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
def __init__(self, responses: list[LLMResponse]) -> None:
|
||||
super().__init__()
|
||||
self._responses = list(responses)
|
||||
self.calls: list[list[dict]] = []
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
@ -24,6 +30,7 @@ class StubProvider(LLMProvider):
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
self.calls.append(messages)
|
||||
if not self._responses:
|
||||
raise AssertionError("No stubbed provider responses left")
|
||||
return self._responses.pop(0)
|
||||
@ -32,9 +39,28 @@ class StubProvider(LLMProvider):
|
||||
return "stub-model"
|
||||
|
||||
|
||||
class StubValidationService:
|
||||
def __init__(self, results: list[ValidationResult]) -> None:
|
||||
self.results = list(results)
|
||||
|
||||
async def validate_task_result(self, **kwargs) -> ValidationResult:
|
||||
if not self.results:
|
||||
raise AssertionError("No stubbed validation results left")
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
class StubTaskExecutionPlanner:
|
||||
def __init__(self, plans: list[TaskExecutionPlan] | None = None) -> None:
|
||||
self.plans = list(plans or [TaskExecutionPlan.single("test-single")])
|
||||
self.calls = []
|
||||
|
||||
async def plan(self, **kwargs) -> TaskExecutionPlan:
|
||||
return TaskExecutionPlan.single("test-single")
|
||||
self.calls.append(kwargs)
|
||||
if len(self.plans) == 1:
|
||||
return self.plans[0]
|
||||
if not self.plans:
|
||||
raise AssertionError("No stubbed execution plans left")
|
||||
return self.plans.pop(0)
|
||||
|
||||
|
||||
class FakeLearningCandidate:
|
||||
@ -42,6 +68,15 @@ class FakeLearningCandidate:
|
||||
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||
|
||||
|
||||
class RecordingSkillAssembler:
|
||||
def __init__(self) -> None:
|
||||
self.task_descriptions: list[str] = []
|
||||
|
||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||
self.task_descriptions.append(kwargs["task_description"])
|
||||
return SkillAssemblyResult()
|
||||
|
||||
|
||||
def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}',
|
||||
@ -70,157 +105,663 @@ def _bundle(*responses: str, route_action: str = "new_task") -> ProviderBundle:
|
||||
)
|
||||
|
||||
|
||||
def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> None:
|
||||
def _single_planner() -> StubTaskExecutionPlanner:
|
||||
return StubTaskExecutionPlanner([TaskExecutionPlan.single("test-single")])
|
||||
|
||||
|
||||
def _team_plan(strategy: str = "sequence") -> TaskExecutionPlan:
|
||||
return TaskExecutionPlan(
|
||||
mode="team",
|
||||
reason="test-team",
|
||||
graph=ExecutionGraph(
|
||||
strategy=strategy, # type: ignore[arg-type]
|
||||
nodes=[
|
||||
ExecutionNode(
|
||||
node_id="research",
|
||||
task="research implementation options",
|
||||
agent=AgentDescriptor(name="researcher", role="research"),
|
||||
)
|
||||
],
|
||||
),
|
||||
final_synthesis_instruction="Use the sub-agent result to produce the final answer.",
|
||||
)
|
||||
|
||||
|
||||
def _provider_bundle(provider: StubProvider) -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=provider,
|
||||
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
auxiliary_provider=StubProvider([_route_response("new_task")]),
|
||||
)
|
||||
|
||||
|
||||
def _main_only_bundle(*responses: str) -> ProviderBundle:
|
||||
return ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[
|
||||
LLMResponse(
|
||||
content=response,
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
)
|
||||
for response in responses
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_simple_question_does_not_create_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService([]),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"draft release notes",
|
||||
session_id="web:test",
|
||||
provider_bundle=_bundle("Done"),
|
||||
"hello?",
|
||||
session_id="web:simple",
|
||||
provider_bundle=_bundle("hi", route_action="simple_chat"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(result.task_id or "")
|
||||
assert task is not None
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.validation_result is None
|
||||
assert result.validation_result is None
|
||||
|
||||
event_types = [event.event_type for event in task_service.list_events(task.task_id)]
|
||||
assert "evidence_recorded" in event_types
|
||||
assert "validated" not in event_types
|
||||
|
||||
|
||||
def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"write implementation plan",
|
||||
session_id="web:acceptance",
|
||||
provider_bundle=_bundle("Plan"),
|
||||
)
|
||||
)
|
||||
|
||||
loaded = service.create_loop().boot()
|
||||
generated: list[tuple[str, str]] = []
|
||||
|
||||
def build_learning_candidates_for_task(
|
||||
task_id: str,
|
||||
*,
|
||||
final_accepted_run_id: str | None = None,
|
||||
trigger_run_id: str | None = None,
|
||||
) -> list[FakeLearningCandidate]:
|
||||
generated.append((task_id, final_accepted_run_id or trigger_run_id or ""))
|
||||
assert result.task_id is None
|
||||
assert loaded.task_service.store.list_tasks() == []
|
||||
|
||||
|
||||
def test_complex_request_creates_task_and_records_validation(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[ValidationResult(passed=True, score=0.9, validator="test")]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement the new report workflow",
|
||||
session_id="web:task",
|
||||
provider_bundle=_bundle("implemented"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task_by_run_id(result.run_id)
|
||||
events = loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
|
||||
run_record = loaded.run_memory_store.list_runs()[-1]
|
||||
skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted")
|
||||
|
||||
assert result.task_id is not None
|
||||
assert task is not None
|
||||
assert task.status == "awaiting_feedback"
|
||||
assert any(event.event_type == "task_validation_snapshotted" for event in events)
|
||||
assert run_record.task_id == result.task_id
|
||||
assert run_record.validation_result["accepted"] is True
|
||||
assert skill_effects.event_payload["candidate_generation_allowed"] is False
|
||||
assert skill_effects.event_payload["learning_candidates"] == []
|
||||
assert task.metadata["short_title"] == "Test task"
|
||||
|
||||
|
||||
def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None:
|
||||
skill_assembler = RecordingSkillAssembler()
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[ValidationResult(passed=True, score=1.0, validator="test")]
|
||||
),
|
||||
skill_assembler=skill_assembler,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"继续按刚才的方案改",
|
||||
session_id="web:task-skill-query",
|
||||
provider_bundle=_bundle("done", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.task_id
|
||||
assert skill_assembler.task_descriptions
|
||||
query = skill_assembler.task_descriptions[0]
|
||||
assert "Task goal:" in query
|
||||
assert "Current user request:" in query
|
||||
assert "Previously activated skills:" in query
|
||||
assert "If no published skill matches, return []" in query
|
||||
|
||||
|
||||
def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement the search workflow",
|
||||
session_id="web:continue",
|
||||
provider_bundle=_bundle("first done", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"also add tests for it",
|
||||
session_id="web:continue",
|
||||
provider_bundle=_bundle("tests added", route_action="continue_task"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(first.task_id)
|
||||
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert len(task.run_ids) == 2
|
||||
|
||||
closed = asyncio.run(
|
||||
service.process_direct(
|
||||
"这个任务结束了",
|
||||
session_id="web:continue",
|
||||
provider_bundle=_bundle("好的,已结束。", route_action="close_task"),
|
||||
)
|
||||
)
|
||||
task = loaded.task_service.get_task(first.task_id)
|
||||
|
||||
assert closed.task_id is None
|
||||
assert task is not None
|
||||
assert task.status == "closed"
|
||||
assert loaded.task_service.active_task_view("web:continue") is None
|
||||
|
||||
|
||||
def test_active_task_revision_input_records_feedback_and_reruns(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
ValidationResult(passed=True, score=0.95, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"查询珠海天气",
|
||||
session_id="web:revise-direct",
|
||||
provider_bundle=_bundle("珠海天气概览", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"再详细一点,并加上明后天穿衣建议",
|
||||
session_id="web:revise-direct",
|
||||
provider_bundle=_bundle("更新后的珠海天气和穿衣建议", route_action="revise_task"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(first.task_id)
|
||||
messages = loaded.session_manager.get_messages_as_conversation(first.session_id)
|
||||
first_assistant = [
|
||||
message
|
||||
for message in messages
|
||||
if message.get("role") == "assistant" and message.get("run_id") == first.run_id
|
||||
][-1]
|
||||
user_messages = [message.get("content") for message in messages if message.get("role") == "user"]
|
||||
|
||||
assert second.task_id == first.task_id
|
||||
assert task is not None
|
||||
assert task.status == "awaiting_feedback"
|
||||
assert len(task.run_ids) == 2
|
||||
assert task.feedback == [
|
||||
{
|
||||
"feedback_type": "revise",
|
||||
"comment": "再详细一点,并加上明后天穿衣建议",
|
||||
"run_id": first.run_id,
|
||||
"created_at": task.feedback[0]["created_at"],
|
||||
}
|
||||
]
|
||||
assert first_assistant["feedback_state"] == "revise"
|
||||
assert "再详细一点,并加上明后天穿衣建议" in user_messages
|
||||
|
||||
|
||||
def test_explicit_revision_feedback_then_input_reruns_without_duplicate_feedback(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
ValidationResult(passed=True, score=0.95, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"查询珠海天气",
|
||||
session_id="web:explicit-revise",
|
||||
provider_bundle=_bundle("珠海天气概览", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
feedback = asyncio.run(
|
||||
service.submit_feedback(
|
||||
session_id=first.session_id,
|
||||
run_id=first.run_id,
|
||||
feedback_type="revise",
|
||||
comment="准备补充穿衣建议",
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"加上明后天穿衣建议",
|
||||
session_id="web:explicit-revise",
|
||||
provider_bundle=_bundle("更新后的珠海天气和穿衣建议", route_action="revise_task"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(first.task_id)
|
||||
|
||||
assert feedback["task_status"] == "needs_revision"
|
||||
assert second.task_id == first.task_id
|
||||
assert task is not None
|
||||
assert task.status == "awaiting_feedback"
|
||||
assert len(task.run_ids) == 2
|
||||
assert len(task.feedback) == 1
|
||||
assert task.feedback[0]["feedback_type"] == "revise"
|
||||
assert task.feedback[0]["comment"] == "准备补充穿衣建议"
|
||||
|
||||
|
||||
def test_validation_failure_retries_once(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(
|
||||
passed=False,
|
||||
score=0.2,
|
||||
issues=["missing tests"],
|
||||
recommended_revision_prompt="Add tests before final response.",
|
||||
validator="test",
|
||||
),
|
||||
ValidationResult(passed=True, score=0.88, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement and validate the task",
|
||||
session_id="web:retry",
|
||||
provider_bundle=_bundle("first draft", "revised draft"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(result.task_id)
|
||||
|
||||
assert result.output_text == "revised draft"
|
||||
assert result.validation_result["accepted"] is True
|
||||
assert task is not None
|
||||
assert len(task.run_ids) == 2
|
||||
visible_messages = loaded.session_manager.get_messages_as_conversation(result.session_id)
|
||||
visible_contents = [message.get("content") for message in visible_messages]
|
||||
assert "first draft" not in visible_contents
|
||||
assert "revised draft" in visible_contents
|
||||
|
||||
|
||||
def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[ValidationResult(passed=True, score=0.9, validator="test")]
|
||||
),
|
||||
)
|
||||
)
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement feedback handling",
|
||||
session_id="web:feedback",
|
||||
provider_bundle=_bundle("done"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
learning_calls = []
|
||||
|
||||
def build_learning_candidates_for_task(task_id: str, *, trigger_run_id: str) -> list[FakeLearningCandidate]:
|
||||
learning_calls.append((task_id, trigger_run_id))
|
||||
return [FakeLearningCandidate()]
|
||||
|
||||
loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
|
||||
|
||||
response = asyncio.run(
|
||||
service.submit_acceptance(
|
||||
session_id="web:acceptance",
|
||||
run_id=result.run_id,
|
||||
acceptance_type="accept",
|
||||
)
|
||||
)
|
||||
|
||||
assert response["task_status"] == "closed"
|
||||
assert response["acceptance_type"] == "accept"
|
||||
assert response["learning_candidates"] == [
|
||||
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||
]
|
||||
assert generated == [(result.task_id, result.run_id)]
|
||||
|
||||
task_service = loaded.task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(result.task_id or "")
|
||||
assert task is not None
|
||||
assert task.metadata["final_accepted_run_id"] == result.run_id
|
||||
|
||||
|
||||
def test_revise_and_abandon_do_not_trigger_learning(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"summarize notes",
|
||||
session_id="web:revise",
|
||||
provider_bundle=_bundle("Summary"),
|
||||
)
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
service.submit_acceptance(
|
||||
session_id="web:revise",
|
||||
run_id=result.run_id,
|
||||
acceptance_type="revise",
|
||||
comment="Add decisions",
|
||||
)
|
||||
)
|
||||
|
||||
assert response["task_status"] == "needs_revision"
|
||||
assert response["learning_candidates"] == []
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(result.task_id or "")
|
||||
assert task is not None
|
||||
assert task.feedback[0]["acceptance_type"] == "revise"
|
||||
|
||||
|
||||
def test_legacy_feedback_endpoint_maps_satisfied_to_accept(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"prepare checklist",
|
||||
session_id="web:legacy",
|
||||
provider_bundle=_bundle("Checklist"),
|
||||
)
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
feedback = asyncio.run(
|
||||
service.submit_feedback(
|
||||
session_id="web:legacy",
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
feedback_type="satisfied",
|
||||
)
|
||||
)
|
||||
|
||||
assert response["acceptance_type"] == "accept"
|
||||
assert response["feedback_type"] == "satisfied"
|
||||
assert response["task_status"] == "closed"
|
||||
assert feedback["task_status"] == "closed"
|
||||
assert feedback["learning_candidates"] == [
|
||||
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
||||
]
|
||||
assert learning_calls == [(result.task_id, result.run_id)]
|
||||
|
||||
service2 = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path / "abandon",
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(passed=False, score=0.3, validator="test"),
|
||||
ValidationResult(passed=False, score=0.3, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
abandoned = asyncio.run(
|
||||
service2.process_direct(
|
||||
"implement another workflow",
|
||||
session_id="web:abandon",
|
||||
provider_bundle=_bundle("not enough", "still not enough"),
|
||||
)
|
||||
)
|
||||
abandon_feedback = asyncio.run(
|
||||
service2.submit_feedback(
|
||||
session_id=abandoned.session_id,
|
||||
run_id=abandoned.run_id,
|
||||
feedback_type="abandon",
|
||||
comment="too costly",
|
||||
)
|
||||
)
|
||||
|
||||
assert abandon_feedback["task_status"] == "abandoned"
|
||||
assert abandon_feedback["learning_candidates"] == []
|
||||
loaded2 = service2.create_loop().boot()
|
||||
failure_events = [
|
||||
event
|
||||
for event in loaded2.session_manager.get_run_event_records(abandoned.session_id, abandoned.run_id)
|
||||
if event.event_type == "task_failure_evidence_recorded"
|
||||
]
|
||||
assert len(failure_events) == 1
|
||||
assert loaded2.memory_service.get_store().memory_entries == []
|
||||
|
||||
|
||||
def test_task_service_maps_legacy_status_and_feedback(tmp_path: Path) -> None:
|
||||
service = TaskService(tmp_path)
|
||||
task = service.create_task(session_id="s", description="legacy")
|
||||
task.status = "awaiting_feedback"
|
||||
task.feedback.append({"feedback_type": "satisfied", "run_id": "run-1"})
|
||||
service.store.upsert_task(task)
|
||||
def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=_single_planner(),
|
||||
validation_service=StubValidationService(
|
||||
[ValidationResult(passed=True, score=0.9, validator="test")]
|
||||
),
|
||||
)
|
||||
)
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement feedback projection",
|
||||
session_id="web:feedback-projection",
|
||||
provider_bundle=_bundle("done"),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
|
||||
loaded = service.get_task(task.task_id)
|
||||
first = asyncio.run(
|
||||
service.submit_feedback(
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
feedback_type="satisfied",
|
||||
)
|
||||
)
|
||||
second = asyncio.run(
|
||||
service.submit_feedback(
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
feedback_type="satisfied",
|
||||
)
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.status == "awaiting_acceptance"
|
||||
assert loaded.feedback[0]["acceptance_type"] == "accept"
|
||||
feedback_events = [
|
||||
event
|
||||
for event in loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
|
||||
if event.event_type == "task_feedback_recorded"
|
||||
]
|
||||
assistant = [
|
||||
message
|
||||
for message in loaded.session_manager.get_messages_as_conversation(result.session_id)
|
||||
if message.get("role") == "assistant" and message.get("run_id") == result.run_id
|
||||
][-1]
|
||||
|
||||
assert first["task_status"] == "closed"
|
||||
assert second["task_status"] == "closed"
|
||||
assert len(feedback_events) == 1
|
||||
assert assistant["feedback_state"] == "satisfied"
|
||||
assert assistant["task_status"] == "closed"
|
||||
assert assistant["validation_status"] == "passed"
|
||||
|
||||
with pytest.raises(ValueError, match="already recorded"):
|
||||
asyncio.run(
|
||||
service.submit_feedback(
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
feedback_type="abandon",
|
||||
)
|
||||
)
|
||||
|
||||
task = loaded.task_service.get_task(result.task_id)
|
||||
assert task is not None
|
||||
assert task.status == "closed"
|
||||
|
||||
|
||||
def test_task_mode_team_plan_runs_subagent_then_main_synthesis(tmp_path: Path) -> None:
|
||||
main_provider = StubProvider(
|
||||
[
|
||||
LLMResponse(content="final synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model")
|
||||
]
|
||||
)
|
||||
sub_provider = StubProvider(
|
||||
[
|
||||
LLMResponse(content="sub-agent evidence", finish_reason="stop", provider_name="stub", model="stub-model")
|
||||
]
|
||||
)
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner([_team_plan()]),
|
||||
validation_service=StubValidationService([ValidationResult(passed=True, score=0.9, validator="test")]),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement team-backed workflow",
|
||||
session_id="web:team",
|
||||
provider_bundle=_provider_bundle(main_provider),
|
||||
team_provider_bundle_factory=lambda node: _provider_bundle(sub_provider),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(result.task_id)
|
||||
events = loaded.session_manager.get_event_records(result.session_id)
|
||||
|
||||
assert result.output_text == "final synthesized answer"
|
||||
assert task is not None
|
||||
assert len(task.run_ids) == 2
|
||||
assert result.run_id == task.run_ids[-1]
|
||||
assert any(event.event_type == "task_execution_planned" for event in events)
|
||||
assert any(event.event_type == "task_team_run_completed" for event in events)
|
||||
assert "sub-agent evidence" in main_provider.calls[0][0]["content"]
|
||||
assert "sub-agent evidence" != result.output_text
|
||||
|
||||
|
||||
def test_task_mode_team_failure_still_uses_main_synthesis(tmp_path: Path) -> None:
|
||||
main_provider = StubProvider(
|
||||
[
|
||||
LLMResponse(content="fallback synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model")
|
||||
]
|
||||
)
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner([_team_plan()]),
|
||||
validation_service=StubValidationService([ValidationResult(passed=True, score=0.9, validator="test")]),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement workflow despite team failure",
|
||||
session_id="web:team-failure",
|
||||
provider_bundle=_provider_bundle(main_provider),
|
||||
team_provider_bundle_factory=lambda node: (_ for _ in ()).throw(RuntimeError("sub-agent unavailable")),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
events = loaded.session_manager.get_event_records(result.session_id)
|
||||
|
||||
assert result.output_text == "fallback synthesized answer"
|
||||
assert any(event.event_type == "task_team_run_failed" for event in events)
|
||||
assert "sub-agent unavailable" in main_provider.calls[0][0]["content"]
|
||||
assert "same class of tools fails repeatedly" in main_provider.calls[0][0]["content"]
|
||||
assert "user-visible fallback answer" in main_provider.calls[0][0]["content"]
|
||||
|
||||
|
||||
def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
|
||||
main_provider = StubProvider(
|
||||
[
|
||||
LLMResponse(content="first synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model"),
|
||||
LLMResponse(content="revised synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model"),
|
||||
]
|
||||
)
|
||||
sub_providers = [
|
||||
StubProvider([LLMResponse(content="first evidence", finish_reason="stop", provider_name="stub", model="stub-model")]),
|
||||
StubProvider([LLMResponse(content="second evidence", finish_reason="stop", provider_name="stub", model="stub-model")]),
|
||||
]
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner([_team_plan(), _team_plan()]),
|
||||
validation_service=StubValidationService(
|
||||
[
|
||||
ValidationResult(passed=False, score=0.2, recommended_revision_prompt="revise", validator="test"),
|
||||
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.process_direct(
|
||||
"implement and validate with team",
|
||||
session_id="web:team-retry",
|
||||
provider_bundle=_provider_bundle(main_provider),
|
||||
team_provider_bundle_factory=lambda node: _provider_bundle(sub_providers.pop(0)),
|
||||
)
|
||||
)
|
||||
loaded = service.create_loop().boot()
|
||||
task = loaded.task_service.get_task(result.task_id)
|
||||
visible = loaded.session_manager.get_messages_as_conversation(result.session_id)
|
||||
visible_contents = [message.get("content") for message in visible]
|
||||
run_records = {record.run_id: record for record in loaded.run_memory_store.list_runs()}
|
||||
|
||||
assert result.output_text == "revised synthesized answer"
|
||||
assert task is not None
|
||||
assert len(task.run_ids) == 4
|
||||
assert "first synthesized answer" not in visible_contents
|
||||
assert "revised synthesized answer" in visible_contents
|
||||
for run_id in task.run_ids:
|
||||
record = run_records[run_id]
|
||||
events = loaded.session_manager.get_run_event_records(record.session_id, run_id)
|
||||
skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"]
|
||||
assert skill_effects
|
||||
assert skill_effects[-1].event_payload["candidate_generation_allowed"] is False
|
||||
|
||||
|
||||
def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None:
|
||||
result = ContextBuilder().build_messages(
|
||||
ContextBuildInput(
|
||||
history=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"run_id": "run-1",
|
||||
"task_id": "task-1",
|
||||
"task_status": "closed",
|
||||
"validation_status": "passed",
|
||||
"feedback_state": "satisfied",
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assistant = result.messages[-1]
|
||||
assert assistant == {"role": "assistant", "content": "done"}
|
||||
|
||||
|
||||
def test_context_builder_normalizes_persisted_tool_arguments() -> None:
|
||||
result = ContextBuilder().build_messages(
|
||||
ContextBuildInput(
|
||||
history=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cron",
|
||||
"arguments": {"action": "add", "mode": "notification"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
tool_call = result.messages[-1]["tool_calls"][0]
|
||||
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'
|
||||
|
||||
|
||||
def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None:
|
||||
task_service = TaskService(tmp_path / "tasks")
|
||||
task = task_service.create_task(session_id="web:validator", description="implement validator handling")
|
||||
validation = asyncio.run(
|
||||
ValidationService().validate_task_result(
|
||||
task=task,
|
||||
user_message="implement validator handling",
|
||||
final_output="done",
|
||||
provider_bundle=_main_only_bundle("not json"),
|
||||
)
|
||||
)
|
||||
|
||||
assert validation.accepted is False
|
||||
assert validation.validator == "llm_error"
|
||||
assert validation.issues
|
||||
|
||||
153
app-instance/backend/tests/unit/test_user_file_service.py
Normal file
153
app-instance/backend/tests/unit/test_user_file_service.py
Normal file
@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver.services.user_files import (
|
||||
LocalUserFileStorage,
|
||||
MinIOStorageConfig,
|
||||
MinIOUserFileStorage,
|
||||
UserFileNotFoundError,
|
||||
UserFilePathError,
|
||||
UserFileSizeError,
|
||||
UserFileService,
|
||||
normalize_user_path,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_user_path_accepts_fixed_roots() -> None:
|
||||
assert normalize_user_path("uploads/readme.txt", allow_root=False) == "uploads/readme.txt"
|
||||
assert normalize_user_path("outputs/report.md", allow_root=False) == "outputs/report.md"
|
||||
assert normalize_user_path("tasks/task-123/draft.md", allow_root=False) == "tasks/task-123/draft.md"
|
||||
assert normalize_user_path("", allow_root=True) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"../secret.txt",
|
||||
"/uploads/input.txt",
|
||||
"/outputs/result.txt",
|
||||
"/shared/profile.json",
|
||||
"/tasks/task-123/draft.md",
|
||||
"uploads/../state/config.json",
|
||||
"memory/private.txt",
|
||||
"uploads/.internal",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_normalize_user_path_rejects_invalid_paths(path: str) -> None:
|
||||
with pytest.raises(UserFilePathError):
|
||||
normalize_user_path(path, allow_root=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_root_and_round_trip(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
root = await service.browse("")
|
||||
uploaded = await service.upload(
|
||||
"uploads",
|
||||
"hello.txt",
|
||||
b"hello user files",
|
||||
content_type="text/plain",
|
||||
)
|
||||
uploads = await service.browse("uploads")
|
||||
preview = await service.preview("uploads/hello.txt")
|
||||
downloaded = await service.download("uploads/hello.txt")
|
||||
deleted = await service.delete("uploads/hello.txt")
|
||||
|
||||
assert [item["name"] for item in root["items"]] == ["uploads", "outputs", "shared", "tasks"]
|
||||
assert uploaded["path"] == "uploads/hello.txt"
|
||||
assert uploaded["content_type"] == "text/plain"
|
||||
assert [item["name"] for item in uploads["items"]] == ["hello.txt"]
|
||||
assert preview["content"] == "hello user files"
|
||||
assert downloaded.content == b"hello user files"
|
||||
assert deleted is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_stream_upload_and_size_limit(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
uploaded = await service.upload_stream(
|
||||
"uploads",
|
||||
"streamed.txt",
|
||||
BytesIO(b"streamed user file"),
|
||||
content_type="text/plain",
|
||||
max_bytes=1024,
|
||||
part_size=4,
|
||||
)
|
||||
preview = await service.preview("uploads/streamed.txt")
|
||||
|
||||
assert uploaded["path"] == "uploads/streamed.txt"
|
||||
assert uploaded["size"] == len(b"streamed user file")
|
||||
assert preview["content"] == "streamed user file"
|
||||
|
||||
with pytest.raises(UserFileSizeError):
|
||||
await service.upload_stream(
|
||||
"uploads",
|
||||
"too-large.txt",
|
||||
BytesIO(b"abcdef"),
|
||||
content_type="text/plain",
|
||||
max_bytes=5,
|
||||
part_size=2,
|
||||
)
|
||||
with pytest.raises(UserFileNotFoundError):
|
||||
await service.preview("uploads/too-large.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_rejects_root_delete_and_traversal(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
await service.delete("uploads")
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
await service.upload("../workspace", "hello.txt", b"x", content_type="text/plain")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_creates_nested_directories(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
created = await service.mkdir("tasks/task-123/references")
|
||||
tasks = await service.browse("tasks/task-123")
|
||||
|
||||
assert created["path"] == "tasks/task-123/references"
|
||||
assert created["type"] == "directory"
|
||||
assert [item["name"] for item in tasks["items"]] == ["references"]
|
||||
|
||||
|
||||
def test_minio_storage_maps_virtual_paths_under_namespace() -> None:
|
||||
storage = object.__new__(MinIOUserFileStorage)
|
||||
storage.config = MinIOStorageConfig(
|
||||
endpoint="minio.local:9000",
|
||||
access_key="alice-access",
|
||||
secret_key="alice-secret",
|
||||
bucket="beaver-user-files",
|
||||
namespace="users/alice",
|
||||
)
|
||||
|
||||
assert storage._object_name("uploads/report.pdf") == "users/alice/uploads/report.pdf"
|
||||
assert storage._object_name("tasks/task-123/result.json") == "users/alice/tasks/task-123/result.json"
|
||||
assert storage._user_path("users/alice/outputs/summary.md") == "outputs/summary.md"
|
||||
|
||||
|
||||
def test_minio_storage_rejects_paths_that_escape_namespace() -> None:
|
||||
storage = object.__new__(MinIOUserFileStorage)
|
||||
storage.config = MinIOStorageConfig(
|
||||
endpoint="minio.local:9000",
|
||||
access_key="alice-access",
|
||||
secret_key="alice-secret",
|
||||
bucket="beaver-user-files",
|
||||
namespace="users/alice",
|
||||
)
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
storage._object_name("uploads/../state/config.json")
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
storage._user_path("users/bob/uploads/secret.txt")
|
||||
177
app-instance/backend/tests/unit/test_user_file_tools.py
Normal file
177
app-instance/backend/tests/unit/test_user_file_tools.py
Normal file
@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver.foundation.config.schema import AuthzConfig, BackendIdentityConfig, BeaverConfig
|
||||
from beaver.tools.base import ObjectBackedTool, ToolContext
|
||||
from beaver.tools.builtins import (
|
||||
UserFilesCopyToWorkspaceTool,
|
||||
UserFilesListTool,
|
||||
UserFilesPublishOutputTool,
|
||||
UserFilesReadTool,
|
||||
UserFilesWriteTool,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_write_read_and_list(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path))
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
read = ObjectBackedTool(UserFilesReadTool())
|
||||
list_files = ObjectBackedTool(UserFilesListTool())
|
||||
|
||||
written = await write.invoke(
|
||||
{"path": "outputs/summary.md", "content": "# Summary", "content_type": "text/markdown"},
|
||||
context,
|
||||
)
|
||||
listed = await list_files.invoke({"path": "outputs"}, context)
|
||||
loaded = await read.invoke({"path": "outputs/summary.md"}, context)
|
||||
|
||||
assert written.success is True
|
||||
assert json.loads(written.content)["path"] == "outputs/summary.md"
|
||||
assert listed.success is True
|
||||
assert [item["name"] for item in json.loads(listed.content)["items"]] == ["summary.md"]
|
||||
assert loaded.success is True
|
||||
assert json.loads(loaded.content)["content"] == "# Summary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_reject_agent_write_to_uploads(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path))
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
|
||||
result = await write.invoke({"path": "uploads/notes.txt", "content": "changed"}, context)
|
||||
|
||||
assert result.success is False
|
||||
assert "uploads/ is user-provided input storage" in (result.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_enforce_current_task_namespace(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path), services={"task_id": "task-123"})
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
|
||||
current = await write.invoke({"path": "tasks/task-123/drafts/notes.md", "content": "ok"}, context)
|
||||
direct = await write.invoke({"path": "tasks/notes.md", "content": "bad"}, context)
|
||||
other = await write.invoke({"path": "tasks/task-456/notes.md", "content": "bad"}, context)
|
||||
|
||||
assert current.success is True
|
||||
assert direct.success is False
|
||||
assert "tasks/task-123/" in (direct.error or "")
|
||||
assert other.success is False
|
||||
assert "tasks/task-123/" in (other.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_allow_shared_context_write(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path), services={"task_id": "task-123"})
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
read = ObjectBackedTool(UserFilesReadTool())
|
||||
|
||||
written = await write.invoke({"path": "shared/profile.json", "content": "{\"name\":\"Alice\"}"}, context)
|
||||
loaded = await read.invoke({"path": "shared/profile.json"}, context)
|
||||
|
||||
assert written.success is True
|
||||
assert loaded.success is True
|
||||
assert json.loads(loaded.content)["content"] == "{\"name\":\"Alice\"}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_copy_to_workspace_and_publish_output(tmp_path) -> None:
|
||||
uploads_dir = tmp_path / "user_files" / "uploads"
|
||||
uploads_dir.mkdir(parents=True)
|
||||
(uploads_dir / "get_helm.sh").write_text(": ${USE_SUDO:=\"true\"}\n", encoding="utf-8")
|
||||
context = ToolContext(
|
||||
workspace=str(tmp_path),
|
||||
services={"task_id": "task-123"},
|
||||
metadata={"run_id": "run-1"},
|
||||
)
|
||||
copy_tool = ObjectBackedTool(UserFilesCopyToWorkspaceTool())
|
||||
publish_tool = ObjectBackedTool(UserFilesPublishOutputTool())
|
||||
read = ObjectBackedTool(UserFilesReadTool())
|
||||
|
||||
copied = await copy_tool.invoke({"path": "uploads/get_helm.sh"}, context)
|
||||
copied_payload = json.loads(copied.content)
|
||||
staged = tmp_path / copied_payload["workspace_path"]
|
||||
staged.write_text(": ${USE_SUDO:=\"false\"}\n", encoding="utf-8")
|
||||
published = await publish_tool.invoke(
|
||||
{"source_path": copied_payload["workspace_path"], "target_path": "outputs/get_helm.no-sudo.sh"},
|
||||
context,
|
||||
)
|
||||
original = await read.invoke({"path": "uploads/get_helm.sh"}, context)
|
||||
output = await read.invoke({"path": "outputs/get_helm.no-sudo.sh"}, context)
|
||||
|
||||
assert copied.success is True
|
||||
assert copied_payload["workspace_path"] == "user-files/tasks/task-123/get_helm.sh"
|
||||
assert published.success is True
|
||||
assert json.loads(original.content)["content"] == ": ${USE_SUDO:=\"true\"}\n"
|
||||
assert json.loads(output.content)["content"] == ": ${USE_SUDO:=\"false\"}\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_publish_rejects_non_output_target_and_workspace_escape(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path))
|
||||
source = tmp_path / "result.txt"
|
||||
source.write_text("done", encoding="utf-8")
|
||||
outside = tmp_path.parent / "outside.txt"
|
||||
outside.write_text("outside", encoding="utf-8")
|
||||
publish_tool = ObjectBackedTool(UserFilesPublishOutputTool())
|
||||
|
||||
upload_target = await publish_tool.invoke({"source_path": "result.txt", "target_path": "uploads/result.txt"}, context)
|
||||
escaped_source = await publish_tool.invoke({"source_path": str(outside), "target_path": "outputs/result.txt"}, context)
|
||||
|
||||
assert upload_target.success is False
|
||||
assert "outputs/" in (upload_target.error or "")
|
||||
assert escaped_source.success is False
|
||||
assert "escapes workspace" in (escaped_source.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_reject_internal_workspace_paths(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path))
|
||||
read = ObjectBackedTool(UserFilesReadTool())
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
|
||||
read_result = await read.invoke({"path": "uploads/../../state/secrets.json"}, context)
|
||||
write_result = await write.invoke({"path": "workspace/debug.txt", "content": "x"}, context)
|
||||
|
||||
assert read_result.success is False
|
||||
assert "Parent-directory traversal" in read_result.error
|
||||
assert write_result.success is False
|
||||
assert "Path must be under" in write_result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_reject_absolute_style_user_paths(tmp_path) -> None:
|
||||
context = ToolContext(workspace=str(tmp_path), services={"task_id": "task-123"})
|
||||
read = ObjectBackedTool(UserFilesReadTool())
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
list_files = ObjectBackedTool(UserFilesListTool())
|
||||
|
||||
read_result = await read.invoke({"path": "/uploads/input.txt"}, context)
|
||||
write_result = await write.invoke({"path": "/outputs/result.txt", "content": "x"}, context)
|
||||
task_write = await write.invoke({"path": "/tasks/task-123/draft.md", "content": "x"}, context)
|
||||
list_result = await list_files.invoke({"path": "/shared/profile.json"}, context)
|
||||
|
||||
for result in (read_result, write_result, task_write, list_result):
|
||||
assert result.success is False
|
||||
assert "Absolute paths are not allowed" in (result.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_tools_report_missing_deployed_minio_settings(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("BEAVER_AUTHZ_INTERNAL_TOKEN", raising=False)
|
||||
monkeypatch.delenv("AUTHZ_INTERNAL_TOKEN", raising=False)
|
||||
config = BeaverConfig(
|
||||
authz=AuthzConfig(enabled=True, base_url="http://authz.local"),
|
||||
backend_identity=BackendIdentityConfig(backend_id="alice", client_id="alice", client_secret="secret"),
|
||||
)
|
||||
context = ToolContext(workspace=str(tmp_path), services={"beaver_config": config})
|
||||
write = ObjectBackedTool(UserFilesWriteTool())
|
||||
|
||||
result = await write.invoke({"path": "outputs/summary.md", "content": "# Summary"}, context)
|
||||
|
||||
assert result.success is False
|
||||
assert "AuthZ internal token is not configured" in (result.error or "")
|
||||
@ -6,6 +6,14 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
from beaver.services.user_file_resolver import UserFileStorageResolver
|
||||
from beaver.services.user_files import LocalUserFileStorage, UserFileService
|
||||
|
||||
|
||||
def _auth_headers(app, username: str = "alice") -> dict[str, str]:
|
||||
token = f"test-token-{username}"
|
||||
app.state.auth_tokens[token] = username
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_workspace_browser_api_manages_workspace_files(tmp_path: Path) -> None:
|
||||
@ -68,3 +76,145 @@ def test_attachment_file_api_round_trips_uploaded_file(tmp_path: Path) -> None:
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"ok": True}
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_user_files_api_uses_virtual_roots_and_hides_workspace(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
headers = _auth_headers(app)
|
||||
root = client.get("/api/user-files/browse", headers=headers)
|
||||
status = client.get("/api/user-files/status", headers=headers)
|
||||
upload = client.post(
|
||||
"/api/user-files/upload",
|
||||
data={"path": "uploads"},
|
||||
files={"file": ("hello.txt", b"hello user files", "text/plain")},
|
||||
headers=headers,
|
||||
)
|
||||
uploads = client.get("/api/user-files/browse", params={"path": "uploads"}, headers=headers)
|
||||
preview = client.get("/api/user-files/preview", params={"path": "uploads/hello.txt"}, headers=headers)
|
||||
download = client.get("/api/user-files/download", params={"path": "uploads/hello.txt"}, headers=headers)
|
||||
|
||||
assert root.status_code == 200
|
||||
assert [item["name"] for item in root.json()["items"]] == ["uploads", "outputs", "shared", "tasks"]
|
||||
assert all("bucket" not in item for item in root.json()["items"])
|
||||
assert status.status_code == 200
|
||||
assert status.json()["workspace_visible"] is False
|
||||
assert "base_path" not in status.json()
|
||||
assert upload.status_code == 200
|
||||
assert upload.json()["path"] == "uploads/hello.txt"
|
||||
assert uploads.status_code == 200
|
||||
assert [item["name"] for item in uploads.json()["items"]] == ["hello.txt"]
|
||||
assert preview.status_code == 200
|
||||
assert preview.json()["content"] == "hello user files"
|
||||
assert download.status_code == 200
|
||||
assert download.content == b"hello user files"
|
||||
|
||||
|
||||
def test_user_files_api_rejects_invalid_paths_and_root_delete(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
headers = _auth_headers(app)
|
||||
traversal = client.get("/api/user-files/browse", params={"path": "uploads/../state"}, headers=headers)
|
||||
unknown_root = client.get("/api/user-files/browse", params={"path": "memory/private.txt"}, headers=headers)
|
||||
absolute_browse = client.get("/api/user-files/browse", params={"path": "/uploads/input.txt"}, headers=headers)
|
||||
absolute_download = client.get("/api/user-files/download", params={"path": "/outputs/result.txt"}, headers=headers)
|
||||
absolute_preview = client.get("/api/user-files/preview", params={"path": "/shared/profile.json"}, headers=headers)
|
||||
absolute_mkdir = client.post("/api/user-files/mkdir", params={"path": "/tasks/task-123/draft.md"}, headers=headers)
|
||||
absolute_upload = client.post(
|
||||
"/api/user-files/upload",
|
||||
data={"path": "/uploads"},
|
||||
files={"file": ("input.txt", b"x", "text/plain")},
|
||||
headers=headers,
|
||||
)
|
||||
delete_root = client.delete("/api/user-files/delete", params={"path": "uploads"}, headers=headers)
|
||||
|
||||
assert traversal.status_code == 400
|
||||
assert unknown_root.status_code == 400
|
||||
assert absolute_browse.status_code == 400
|
||||
assert absolute_download.status_code == 400
|
||||
assert absolute_preview.status_code == 400
|
||||
assert absolute_mkdir.status_code == 400
|
||||
assert absolute_upload.status_code == 400
|
||||
assert delete_root.status_code == 400
|
||||
|
||||
|
||||
def test_user_files_api_rejects_anonymous_access_before_storage(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
browse = client.get("/api/user-files/browse")
|
||||
status = client.get("/api/user-files/status")
|
||||
upload = client.post(
|
||||
"/api/user-files/upload",
|
||||
data={"path": "uploads"},
|
||||
files={"file": ("hello.txt", b"hello user files", "text/plain")},
|
||||
)
|
||||
delete = client.delete("/api/user-files/delete", params={"path": "uploads/hello.txt"})
|
||||
mkdir = client.post("/api/user-files/mkdir", params={"path": "uploads/new"})
|
||||
|
||||
assert browse.status_code == 401
|
||||
assert status.status_code == 401
|
||||
assert upload.status_code == 401
|
||||
assert delete.status_code == 401
|
||||
assert mkdir.status_code == 401
|
||||
|
||||
|
||||
def test_user_files_api_authenticated_request_resolves_identity(tmp_path: Path, monkeypatch) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
seen = []
|
||||
|
||||
async def fake_service(self):
|
||||
seen.append(self.auth_context)
|
||||
return UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
monkeypatch.setattr(UserFileStorageResolver, "service", fake_service)
|
||||
|
||||
with TestClient(app) as client:
|
||||
alice_headers = _auth_headers(app, "alice")
|
||||
upload = client.post(
|
||||
"/api/user-files/upload",
|
||||
data={"path": "uploads"},
|
||||
files={"file": ("alice.txt", b"alice", "text/plain")},
|
||||
headers=alice_headers,
|
||||
)
|
||||
|
||||
assert upload.status_code == 200
|
||||
assert seen
|
||||
assert seen[0].username == "alice"
|
||||
assert seen[0].backend_id == "alice"
|
||||
assert seen[0].storage_namespace == "users/alice"
|
||||
|
||||
|
||||
def test_user_files_api_streams_upload_and_enforces_configured_limit(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", "5")
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
headers = _auth_headers(app)
|
||||
ok_upload = client.post(
|
||||
"/api/user-files/upload",
|
||||
data={"path": "uploads"},
|
||||
files={"file": ("small.txt", b"abcde", "text/plain")},
|
||||
headers=headers,
|
||||
)
|
||||
too_large = client.post(
|
||||
"/api/user-files/upload",
|
||||
data={"path": "uploads"},
|
||||
files={"file": ("large.txt", b"abcdef", "text/plain")},
|
||||
headers=headers,
|
||||
)
|
||||
preview = client.get("/api/user-files/preview", params={"path": "uploads/small.txt"}, headers=headers)
|
||||
|
||||
assert ok_upload.status_code == 200
|
||||
assert ok_upload.json()["path"] == "uploads/small.txt"
|
||||
assert too_large.status_code == 413
|
||||
assert "File too large" in too_large.json()["detail"]
|
||||
assert preview.status_code == 200
|
||||
assert preview.json()["content"] == "abcde"
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.tools.builtins import web
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
headers = {"content-type": "text/html"}
|
||||
status_code = 200
|
||||
text = '<a class="result__a" href="https://example.com">Example</a>'
|
||||
url = "https://example.com"
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *args: object, **kwargs: object) -> _FakeResponse:
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
def test_web_tools_use_environment_proxy_settings(monkeypatch) -> None:
|
||||
_FakeAsyncClient.calls = []
|
||||
monkeypatch.setattr(web.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
|
||||
async def _run() -> None:
|
||||
await web.WebFetchTool().execute(url="https://example.com")
|
||||
await web.WebSearchTool().execute(query="example")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert [call.get("trust_env") for call in _FakeAsyncClient.calls] == [True, True]
|
||||
@ -20,8 +20,8 @@ class StubRunResult:
|
||||
model: str | None = "stub-model"
|
||||
usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3})
|
||||
task_id: str | None = "task-1"
|
||||
task_status: str | None = "awaiting_acceptance"
|
||||
validation_result: dict[str, Any] | None = None
|
||||
task_status: str | None = "awaiting_feedback"
|
||||
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
||||
|
||||
|
||||
class StubAgentService(AgentService):
|
||||
@ -30,15 +30,6 @@ class StubAgentService(AgentService):
|
||||
self.fail = fail
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def process_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
|
||||
self.calls.append({"message": message, **kwargs})
|
||||
if self.fail:
|
||||
raise RuntimeError("boom")
|
||||
return StubRunResult(
|
||||
session_id=kwargs.get("session_id") or "web:default",
|
||||
output_text=f"echo:{message}",
|
||||
)
|
||||
|
||||
async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
|
||||
self.calls.append({"message": message, **kwargs})
|
||||
if self.fail:
|
||||
@ -49,11 +40,6 @@ class StubAgentService(AgentService):
|
||||
)
|
||||
|
||||
|
||||
class DirectModeOnlyAgentService(StubAgentService):
|
||||
async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
|
||||
raise RuntimeError("submit_direct should not be used when service is not running")
|
||||
|
||||
|
||||
def test_websocket_ping_pong() -> None:
|
||||
app = create_app(service=StubAgentService(), manage_service_lifecycle=False)
|
||||
|
||||
@ -101,10 +87,9 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
|
||||
assert message["session_id"] == "web:alpha"
|
||||
assert message["run_id"] == "run-1"
|
||||
assert message["task_id"] == "task-1"
|
||||
assert message["task_status"] == "awaiting_acceptance"
|
||||
assert message["evidence_status"] == "recorded"
|
||||
assert message["validation_result"] is None
|
||||
assert "validation_status" not in message
|
||||
assert message["task_status"] == "awaiting_feedback"
|
||||
assert message["validation_result"] == {"accepted": True}
|
||||
assert message["validation_status"] == "passed"
|
||||
assert message["metadata"]["input_metadata"] == {
|
||||
"source": "test",
|
||||
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
|
||||
@ -116,64 +101,6 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_websocket_message_uses_direct_processing_when_loop_is_not_running() -> None:
|
||||
service = DirectModeOnlyAgentService()
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/web:alpha") as websocket:
|
||||
websocket.send_json({"type": "message", "content": "hello"})
|
||||
assert websocket.receive_json() == {"type": "status", "status": "thinking"}
|
||||
message = websocket.receive_json()
|
||||
|
||||
assert service.calls == [
|
||||
{
|
||||
"message": "hello",
|
||||
"session_id": "web:alpha",
|
||||
"source": "websocket",
|
||||
"user_id": None,
|
||||
"title": None,
|
||||
"execution_context": None,
|
||||
"model": None,
|
||||
"provider_name": None,
|
||||
"embedding_model": None,
|
||||
"max_tool_iterations": None,
|
||||
}
|
||||
]
|
||||
assert message["type"] == "message"
|
||||
assert message["content"] == "echo:hello"
|
||||
|
||||
|
||||
def test_rest_chat_uses_direct_processing_when_loop_is_not_running() -> None:
|
||||
service = DirectModeOnlyAgentService()
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/chat", json={"session_id": "web:alpha", "message": "hello"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert service.calls == [
|
||||
{
|
||||
"message": "hello",
|
||||
"session_id": "web:alpha",
|
||||
"source": "web",
|
||||
"user_id": None,
|
||||
"title": None,
|
||||
"execution_context": None,
|
||||
"model": None,
|
||||
"provider_name": None,
|
||||
"embedding_model": None,
|
||||
"temperature": None,
|
||||
"max_tokens": None,
|
||||
"max_tool_iterations": None,
|
||||
"fallback_target": None,
|
||||
"auxiliary_target": None,
|
||||
"embedding_target": None,
|
||||
}
|
||||
]
|
||||
assert response.json()["output_text"] == "echo:hello"
|
||||
|
||||
|
||||
def test_websocket_empty_content_returns_error_without_runtime_call() -> None:
|
||||
service = StubAgentService()
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
91
app-instance/backend/uv.lock
generated
91
app-instance/backend/uv.lock
generated
@ -192,6 +192,49 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi-bindings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
@ -244,6 +287,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "json-repair" },
|
||||
{ name = "litellm" },
|
||||
{ name = "minio" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
@ -265,6 +309,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
|
||||
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" },
|
||||
{ name = "minio", specifier = ">=7.2.0,<8.0.0" },
|
||||
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||
@ -1420,6 +1465,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minio"
|
||||
version = "7.2.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "certifi" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "more-itertools"
|
||||
version = "11.0.2"
|
||||
@ -1759,6 +1820,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
|
||||
@ -15,8 +15,10 @@ CONTAINER_NAME=""
|
||||
HOST_PORT=""
|
||||
PUBLIC_URL=""
|
||||
AUTHZ_BASE_URL=""
|
||||
AUTHZ_INTERNAL_TOKEN=""
|
||||
AUTHZ_OUTLOOK_MCP_URL=""
|
||||
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
||||
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
|
||||
BACKEND_ID=""
|
||||
CLIENT_ID=""
|
||||
CLIENT_SECRET=""
|
||||
@ -61,10 +63,14 @@ Optional:
|
||||
--model <name> Model name. Default: openai/gpt-5
|
||||
--skip-provider-config Create the instance without model/provider/API key settings.
|
||||
--authz-base-url <url> AuthZ service base URL.
|
||||
--authz-internal-token <token>
|
||||
AuthZ internal token for backend-only user file storage settings lookup.
|
||||
--authz-outlook-mcp-url <url>
|
||||
Managed Outlook MCP URL for AuthZ mode.
|
||||
--outlook-mcp-server-id <id>
|
||||
Default Outlook MCP server id. Default: outlook_mcp
|
||||
--user-files-max-upload-bytes <bytes>
|
||||
Optional max upload size for the user file system.
|
||||
--backend-id <id> Pre-assigned backend id.
|
||||
--client-id <id> Pre-assigned AuthZ client id.
|
||||
--client-secret <secret> Pre-assigned AuthZ client secret.
|
||||
@ -138,6 +144,7 @@ render_config_json() {
|
||||
API_BASE="$API_BASE" \
|
||||
SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \
|
||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
|
||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
||||
BACKEND_ID="$BACKEND_ID" \
|
||||
@ -260,6 +267,7 @@ render_runtime_env_file() {
|
||||
|
||||
TARGET_PATH="$target_path" \
|
||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
|
||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||
BACKEND_ID="$BACKEND_ID" \
|
||||
CLIENT_ID="$CLIENT_ID" \
|
||||
@ -275,6 +283,7 @@ target = Path(os.environ["TARGET_PATH"])
|
||||
values = {
|
||||
"BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
|
||||
"BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
|
||||
"BEAVER_AUTHZ_INTERNAL_TOKEN": os.environ["AUTHZ_INTERNAL_TOKEN"].strip(),
|
||||
"BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
|
||||
@ -285,6 +294,7 @@ values = {
|
||||
ordered_keys = [
|
||||
"BEAVER_AUTHZ__ENABLED",
|
||||
"BEAVER_AUTHZ__BASE_URL",
|
||||
"BEAVER_AUTHZ_INTERNAL_TOKEN",
|
||||
"BEAVER_AUTHZ__OUTLOOK_MCP_URL",
|
||||
"BEAVER_BACKEND_IDENTITY__BACKEND_ID",
|
||||
"BEAVER_BACKEND_IDENTITY__CLIENT_ID",
|
||||
@ -380,6 +390,10 @@ while [[ $# -gt 0 ]]; do
|
||||
AUTHZ_BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--authz-internal-token)
|
||||
AUTHZ_INTERNAL_TOKEN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--authz-outlook-mcp-url)
|
||||
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
||||
shift 2
|
||||
@ -388,6 +402,10 @@ while [[ $# -gt 0 ]]; do
|
||||
OUTLOOK_MCP_SERVER_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--user-files-max-upload-bytes)
|
||||
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--backend-id)
|
||||
BACKEND_ID="${2:-}"
|
||||
shift 2
|
||||
@ -570,6 +588,10 @@ RUN_ARGS=(
|
||||
--label "beaver.instance.public_url=${PUBLIC_URL}"
|
||||
)
|
||||
|
||||
if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then
|
||||
RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}")
|
||||
fi
|
||||
|
||||
if [[ -n "$NETWORK_NAME" ]]; then
|
||||
RUN_ARGS+=(--network "$NETWORK_NAME")
|
||||
fi
|
||||
|
||||
@ -4,6 +4,9 @@ set -euo pipefail
|
||||
APP_PUBLIC_PORT="${APP_PUBLIC_PORT:-8080}"
|
||||
APP_FRONTEND_PORT="${APP_FRONTEND_PORT:-3000}"
|
||||
APP_BACKEND_PORT="${APP_BACKEND_PORT:-18080}"
|
||||
UVICORN_LOOP="${UVICORN_LOOP:-asyncio}"
|
||||
UVICORN_HTTP="${UVICORN_HTTP:-h11}"
|
||||
UVICORN_WS="${UVICORN_WS:-websockets}"
|
||||
BEAVER_HOME="${BEAVER_HOME:-/root/.beaver}"
|
||||
BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
|
||||
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
|
||||
@ -59,11 +62,12 @@ export BEAVER_CONFIG_PATH
|
||||
export BEAVER_WORKSPACE
|
||||
export PORT="$APP_FRONTEND_PORT"
|
||||
export HOSTNAME="127.0.0.1"
|
||||
export PYTHONFAULTHANDLER="${PYTHONFAULTHANDLER:-1}"
|
||||
|
||||
log "starting Beaver backend on 127.0.0.1:${APP_BACKEND_PORT}"
|
||||
log "starting Beaver backend on 127.0.0.1:${APP_BACKEND_PORT} (loop=${UVICORN_LOOP}, http=${UVICORN_HTTP}, ws=${UVICORN_WS})"
|
||||
(
|
||||
cd /opt/app/backend
|
||||
python -m uvicorn "beaver.interfaces.web.app:create_app" --factory --host 127.0.0.1 --port "$APP_BACKEND_PORT"
|
||||
python -m uvicorn "beaver.interfaces.web.app:create_app" --factory --host 127.0.0.1 --port "$APP_BACKEND_PORT" --loop "$UVICORN_LOOP" --http "$UVICORN_HTTP" --ws "$UVICORN_WS"
|
||||
) &
|
||||
BACKEND_PID=$!
|
||||
|
||||
|
||||
@ -21,49 +21,71 @@ import {
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
browseWorkspace,
|
||||
getWorkspaceFile,
|
||||
getWorkspaceDownloadUrl,
|
||||
uploadToWorkspace,
|
||||
deleteWorkspacePath,
|
||||
createWorkspaceDir,
|
||||
browseUserFiles,
|
||||
getUserFile,
|
||||
getUserFileDownloadUrl,
|
||||
uploadUserFile,
|
||||
deleteUserFile,
|
||||
createUserFileDir,
|
||||
getAccessToken,
|
||||
} from '@/lib/api';
|
||||
import type { WorkspaceFileContent, WorkspaceItem } from '@/lib/api';
|
||||
import type { UserFileContent, UserFileItem } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [items, setItems] = useState<WorkspaceItem[]>([]);
|
||||
const [items, setItems] = useState<UserFileItem[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [showMkdir, setShowMkdir] = useState(false);
|
||||
const [newDirName, setNewDirName] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<WorkspaceFileContent | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<UserFileContent | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const mkdirInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async (path: string = currentPath) => {
|
||||
let lastError: unknown = null;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await browseWorkspace(path);
|
||||
setItems(data.items);
|
||||
setCurrentPath(data.path);
|
||||
setSelectedFile(null);
|
||||
setPreviewError(null);
|
||||
} catch {
|
||||
// ignore
|
||||
setLoadError(null);
|
||||
for (const delay of LOAD_RETRY_DELAYS_MS) {
|
||||
if (delay > 0) {
|
||||
await sleep(delay);
|
||||
}
|
||||
try {
|
||||
const data = await browseUserFiles(path);
|
||||
setItems(data.items);
|
||||
setCurrentPath(data.path);
|
||||
setSelectedFile(null);
|
||||
setPreviewError(null);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
const message = lastError instanceof Error ? lastError.message : pickAppText(locale, '加载文件失败', 'Failed to load files');
|
||||
setLoadError(message);
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPath]);
|
||||
}, [currentPath, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
load('');
|
||||
@ -73,12 +95,12 @@ export default function FilesPage() {
|
||||
load(path);
|
||||
};
|
||||
|
||||
const openFile = async (item: WorkspaceItem) => {
|
||||
const openFile = async (item: UserFileItem) => {
|
||||
if (item.type !== 'file') return;
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
setSelectedFile(await getWorkspaceFile(item.path));
|
||||
setSelectedFile(await getUserFile(item.path));
|
||||
} catch (err: any) {
|
||||
setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file'));
|
||||
setSelectedFile(null);
|
||||
@ -87,7 +109,7 @@ export default function FilesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: WorkspaceItem) => {
|
||||
const handleDelete = async (item: UserFileItem) => {
|
||||
const label = item.type === 'directory'
|
||||
? pickAppText(locale, '文件夹', 'folder')
|
||||
: pickAppText(locale, '文件', 'file');
|
||||
@ -99,7 +121,7 @@ export default function FilesPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspacePath(item.path);
|
||||
await deleteUserFile(item.path);
|
||||
setItems((prev) => prev.filter((i) => i.path !== item.path));
|
||||
if (selectedFile?.path === item.path) {
|
||||
setSelectedFile(null);
|
||||
@ -109,8 +131,8 @@ export default function FilesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (item: WorkspaceItem) => {
|
||||
const url = getWorkspaceDownloadUrl(item.path);
|
||||
const handleDownload = async (item: UserFileItem) => {
|
||||
const url = getUserFileDownloadUrl(item.path);
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
@ -138,7 +160,7 @@ export default function FilesPage() {
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadToWorkspace(files[i], currentPath, (pct) => {
|
||||
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
|
||||
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
|
||||
});
|
||||
}
|
||||
@ -157,7 +179,7 @@ export default function FilesPage() {
|
||||
if (!name) return;
|
||||
try {
|
||||
const dirPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
await createWorkspaceDir(dirPath);
|
||||
await createUserFileDir(dirPath);
|
||||
setShowMkdir(false);
|
||||
setNewDirName('');
|
||||
await load();
|
||||
@ -176,7 +198,8 @@ export default function FilesPage() {
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
const formatDate = (iso: string | null | undefined) => {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString(locale, {
|
||||
month: '2-digit',
|
||||
@ -199,7 +222,7 @@ export default function FilesPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMkdir(true)}
|
||||
disabled={loading}
|
||||
disabled={loading || !currentPath}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-1" />
|
||||
{pickAppText(locale, '新建文件夹', 'New folder')}
|
||||
@ -208,7 +231,7 @@ export default function FilesPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
disabled={uploading || !currentPath}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
@ -246,7 +269,7 @@ export default function FilesPage() {
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
{pickAppText(locale, '工作区', 'Workspace')}
|
||||
{pickAppText(locale, '文件', 'Files')}
|
||||
</button>
|
||||
{breadcrumbs.map((segment, idx) => {
|
||||
const path = breadcrumbs.slice(0, idx + 1).join('/');
|
||||
@ -312,6 +335,16 @@ export default function FilesPage() {
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
|
||||
<p className="max-w-sm text-center text-sm">{loadError}</p>
|
||||
<Button className="mt-4" variant="outline" size="sm" onClick={() => load()}>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
{pickAppText(locale, '重试', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
||||
@ -340,7 +373,7 @@ export default function FilesPage() {
|
||||
{item.type === 'directory' ? (
|
||||
<Folder className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FileIcon name={item.name} contentType={item.content_type} />
|
||||
<FileIcon name={item.name} contentType={item.content_type || undefined} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -412,7 +445,7 @@ export default function FilesPage() {
|
||||
error={previewError}
|
||||
formatSize={formatSize}
|
||||
formatDate={formatDate}
|
||||
downloadUrl={selectedFile ? getWorkspaceDownloadUrl(selectedFile.path) : null}
|
||||
downloadUrl={selectedFile ? getUserFileDownloadUrl(selectedFile.path) : null}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
@ -429,11 +462,11 @@ function FilePreviewPanel({
|
||||
downloadUrl,
|
||||
locale,
|
||||
}: {
|
||||
file: WorkspaceFileContent | null;
|
||||
file: UserFileContent | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
formatSize: (bytes: number | null) => string;
|
||||
formatDate: (iso: string) => string;
|
||||
formatDate: (iso: string | null | undefined) => string;
|
||||
downloadUrl: string | null;
|
||||
locale: AppLocale;
|
||||
}) {
|
||||
@ -516,10 +549,10 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string })
|
||||
return <FileText className="w-5 h-5 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function isImage(file: WorkspaceFileContent): boolean {
|
||||
function isImage(file: UserFileContent): boolean {
|
||||
return file.content_type.startsWith('image/');
|
||||
}
|
||||
|
||||
function isMarkdown(file: WorkspaceFileContent): boolean {
|
||||
function isMarkdown(file: UserFileContent): boolean {
|
||||
return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown');
|
||||
}
|
||||
|
||||
@ -97,6 +97,16 @@ function transportLabel(transport: string | undefined, locale: AppLocale) {
|
||||
return transport || '-';
|
||||
}
|
||||
|
||||
function discoveredToolCount(
|
||||
serverId: string,
|
||||
tools: Array<{ server_id: string; tools: Array<Record<string, unknown>> }>,
|
||||
fallback?: number,
|
||||
) {
|
||||
const group = tools.find((item) => item.server_id === serverId);
|
||||
if (group) return group.tools.length;
|
||||
return fallback || 0;
|
||||
}
|
||||
|
||||
export default function MCPPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
@ -543,7 +553,7 @@ export default function MCPPage() {
|
||||
<div><span className="font-medium">Scopes:</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</span>
|
||||
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
|
||||
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
|
||||
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
|
||||
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
archiveSession,
|
||||
@ -19,10 +18,9 @@ import {
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||
import { mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
|
||||
@ -32,7 +30,7 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
|
||||
|
||||
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
|
||||
if (status === 'awaiting_acceptance') return pickAppText(locale, '待验收', 'Awaiting acceptance');
|
||||
if (status === 'awaiting_feedback') return pickAppText(locale, '待反馈', 'Awaiting feedback');
|
||||
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
|
||||
return pickAppText(locale, '进行中', 'Active');
|
||||
}
|
||||
@ -41,10 +39,10 @@ const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled';
|
||||
|
||||
function loadThinkingModePreference(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY);
|
||||
return stored == null ? false : stored !== 'false';
|
||||
return stored == null ? true : stored !== 'false';
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
@ -62,9 +60,6 @@ export default function ChatPage() {
|
||||
setSessionId,
|
||||
setMessages,
|
||||
addMessage,
|
||||
setInputDraft,
|
||||
getInputDraft,
|
||||
clearInputDraft,
|
||||
setIsLoading,
|
||||
clearMessages,
|
||||
setIsThinking,
|
||||
@ -73,7 +68,7 @@ export default function ChatPage() {
|
||||
updateMessageFeedback,
|
||||
} = useChatStore();
|
||||
|
||||
const [input, setInput] = useState(() => useChatStore.getState().getInputDraft(useChatStore.getState().sessionId));
|
||||
const [input, setInput] = useState('');
|
||||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||
@ -110,17 +105,6 @@ export default function ChatPage() {
|
||||
);
|
||||
|
||||
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
|
||||
const sessionProgressView = useMemo(
|
||||
() =>
|
||||
buildSessionProgressView({
|
||||
sessionId,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale,
|
||||
}),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessionId]
|
||||
);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
@ -157,11 +141,9 @@ export default function ChatPage() {
|
||||
setSessionProcess(key, process);
|
||||
}
|
||||
void loadActiveTask(key);
|
||||
const displayMessages = detail.messages.filter(shouldDisplayChatMessage);
|
||||
const shouldMergePending = shouldMergePendingUsers(displayMessages, localSnapshot, waitingForReply);
|
||||
const nextMessages = shouldMergePending
|
||||
? mergeServerWithPendingUsers(displayMessages, localSnapshot)
|
||||
: displayMessages;
|
||||
const nextMessages = waitingForReply
|
||||
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
||||
: detail.messages;
|
||||
setMessages(nextMessages);
|
||||
shouldSnapToLatestRef.current = true;
|
||||
const last = nextMessages[nextMessages.length - 1];
|
||||
@ -185,7 +167,6 @@ export default function ChatPage() {
|
||||
}
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
setInput(useChatStore.getState().getInputDraft(sessionId));
|
||||
void loadSessionMessages(sessionId);
|
||||
void loadActiveTask(sessionId);
|
||||
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||
@ -218,11 +199,15 @@ export default function ChatPage() {
|
||||
if (data.type === 'status' && data.status === 'thinking') {
|
||||
setIsThinking(true);
|
||||
} else if (data.type === 'message' && data.role === 'assistant') {
|
||||
const validationResult = data.validation_result ?? data.metadata?.validation_result;
|
||||
const validationStatus = data.validation_status
|
||||
? data.validation_status
|
||||
: validationResult
|
||||
? ((validationResult as Record<string, unknown>).accepted === true ? 'passed' : 'failed')
|
||||
: 'unknown';
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
const rawEvidenceStatus = data.evidence_status ?? data.metadata?.evidence_status;
|
||||
const evidenceStatus = rawEvidenceStatus === 'recorded' ? 'recorded' : undefined;
|
||||
const assistantMessage = {
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -230,11 +215,8 @@ export default function ChatPage() {
|
||||
run_id: typeof data.run_id === 'string' ? data.run_id : undefined,
|
||||
task_id: data.task_id ?? data.metadata?.task_id ?? null,
|
||||
task_status: data.task_status ?? data.metadata?.task_status ?? null,
|
||||
evidence_status: evidenceStatus,
|
||||
} as const;
|
||||
if (shouldDisplayChatMessage(assistantMessage)) {
|
||||
addMessage(assistantMessage);
|
||||
}
|
||||
validation_status: validationStatus,
|
||||
});
|
||||
void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||||
void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||||
loadSessions();
|
||||
@ -326,7 +308,6 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
setInput('');
|
||||
clearInputDraft(sessionId);
|
||||
setPendingFiles([]);
|
||||
addMessage({
|
||||
role: 'user',
|
||||
@ -359,18 +340,17 @@ export default function ChatPage() {
|
||||
await loadSessions();
|
||||
return;
|
||||
}
|
||||
const assistantMessage = {
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: result.response,
|
||||
timestamp: new Date().toISOString(),
|
||||
run_id: result.run_id,
|
||||
task_id: result.task_id,
|
||||
task_status: result.task_status,
|
||||
evidence_status: result.evidence_status === 'recorded' ? 'recorded' : undefined,
|
||||
} as const;
|
||||
if (shouldDisplayChatMessage(assistantMessage)) {
|
||||
addMessage(assistantMessage);
|
||||
}
|
||||
validation_status: result.validation_result
|
||||
? (result.validation_result.accepted === true ? 'passed' : 'failed')
|
||||
: 'unknown',
|
||||
});
|
||||
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
||||
void loadActiveTask(sessionId);
|
||||
loadSessions();
|
||||
@ -392,9 +372,9 @@ export default function ChatPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [addMessage, clearInputDraft, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
||||
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
||||
|
||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => {
|
||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
||||
updateMessageFeedback(runId, feedbackType);
|
||||
try {
|
||||
await submitChatFeedback({
|
||||
@ -453,8 +433,6 @@ export default function ChatPage() {
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
clearInputDraft(id);
|
||||
setInput('');
|
||||
clearMessages();
|
||||
useChatStore.getState().resetProcessState();
|
||||
try {
|
||||
@ -474,8 +452,6 @@ export default function ChatPage() {
|
||||
setSessionId('web:default');
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
clearInputDraft(key);
|
||||
setInput(useChatStore.getState().getInputDraft('web:default'));
|
||||
clearMessages();
|
||||
useChatStore.getState().resetProcessState();
|
||||
}
|
||||
@ -493,7 +469,6 @@ export default function ChatPage() {
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
setInput(useChatStore.getState().getInputDraft(key));
|
||||
setSessionId(key);
|
||||
};
|
||||
|
||||
@ -644,10 +619,7 @@ export default function ChatPage() {
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setInputDraft(sessionId, e.target.value);
|
||||
}}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
revisionTargetRunId
|
||||
@ -706,8 +678,6 @@ export default function ChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1238,7 +1238,7 @@ function riskLabel(risk: string, t: (zh: string, en: string) => string): string
|
||||
|
||||
function triggerReasonLabel(reason: string, t: (zh: string, en: string) => string): string {
|
||||
const labels: Record<string, string> = {
|
||||
task_accepted: t('任务已接受', 'Task accepted'),
|
||||
validation_accepted_and_user_satisfied: t('任务验证通过且用户满意', 'Validation accepted and user satisfied'),
|
||||
};
|
||||
return labels[reason] || reason;
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
Settings2,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { getStatus, updateAgentConfig, updateProviderConfig } from '@/lib/api';
|
||||
import { getStatus, updateProviderConfig } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -42,12 +42,6 @@ type ProviderFormState = {
|
||||
requestTimeoutSeconds: string;
|
||||
};
|
||||
|
||||
type AgentFormState = {
|
||||
maxTokens: string;
|
||||
temperature: string;
|
||||
maxToolIterations: string;
|
||||
};
|
||||
|
||||
export default function StatusPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
@ -63,13 +57,6 @@ export default function StatusPage() {
|
||||
}));
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const [providerError, setProviderError] = useState<string | null>(null);
|
||||
const [agentForm, setAgentForm] = useState<AgentFormState>(() => ({
|
||||
maxTokens: '',
|
||||
temperature: '0.2',
|
||||
maxToolIterations: '30',
|
||||
}));
|
||||
const [savingAgent, setSavingAgent] = useState(false);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
@ -77,11 +64,6 @@ export default function StatusPage() {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
setAgentForm({
|
||||
maxTokens: data.max_tokens == null ? '' : String(data.max_tokens),
|
||||
temperature: String(data.temperature),
|
||||
maxToolIterations: String(data.max_tool_iterations),
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
|
||||
} finally {
|
||||
@ -133,39 +115,6 @@ export default function StatusPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAgentConfig = async () => {
|
||||
setSavingAgent(true);
|
||||
setAgentError(null);
|
||||
try {
|
||||
const maxTokensText = agentForm.maxTokens.trim();
|
||||
const maxTokens = maxTokensText ? Number(maxTokensText) : null;
|
||||
const temperature = Number(agentForm.temperature.trim());
|
||||
const maxToolIterations = Number(agentForm.maxToolIterations.trim());
|
||||
if (
|
||||
maxTokens !== null &&
|
||||
(!Number.isInteger(maxTokens) || maxTokens <= 0)
|
||||
) {
|
||||
throw new Error(pickAppText(locale, '最大令牌数必须为空或正整数', 'Max tokens must be blank or a positive integer'));
|
||||
}
|
||||
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) {
|
||||
throw new Error(pickAppText(locale, '温度必须在 0 到 2 之间', 'Temperature must be between 0 and 2'));
|
||||
}
|
||||
if (!Number.isInteger(maxToolIterations) || maxToolIterations < 0) {
|
||||
throw new Error(pickAppText(locale, '最大工具迭代次数必须是非负整数', 'Max tool iterations must be a non-negative integer'));
|
||||
}
|
||||
await updateAgentConfig({
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
});
|
||||
await loadStatus();
|
||||
} catch (err: any) {
|
||||
setAgentError(err.message || pickAppText(locale, '保存智能体配置失败', 'Failed to save agent configuration'));
|
||||
} finally {
|
||||
setSavingAgent(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@ -258,47 +207,14 @@ export default function StatusPage() {
|
||||
{pickAppText(locale, '智能体配置', 'Agent configuration')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
|
||||
<div className="grid gap-4 border-t pt-5 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-max-tokens">{pickAppText(locale, '最大令牌数', 'Max tokens')}</Label>
|
||||
<Input
|
||||
id="agent-max-tokens"
|
||||
inputMode="numeric"
|
||||
value={agentForm.maxTokens}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxTokens: event.target.value }))}
|
||||
placeholder={pickAppText(locale, '模型默认', 'Model default')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-temperature">{pickAppText(locale, '温度', 'Temperature')}</Label>
|
||||
<Input
|
||||
id="agent-temperature"
|
||||
inputMode="decimal"
|
||||
value={agentForm.temperature}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, temperature: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-max-tool-iterations">
|
||||
{pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-max-tool-iterations"
|
||||
inputMode="numeric"
|
||||
value={agentForm.maxToolIterations}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxToolIterations: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-destructive">{agentError || ''}</div>
|
||||
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
|
||||
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
|
||||
</Button>
|
||||
</div>
|
||||
<InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
|
||||
<InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
|
||||
<InfoRow
|
||||
label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
value={String(status.max_tool_iterations)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -142,7 +142,7 @@ function OrdinaryTasks() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
|
||||
<Badge variant={task.status === 'awaiting_feedback' || task.status === 'closed' ? 'default' : 'secondary'}>
|
||||
{taskStatusLabel(task.status, locale)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@ -185,7 +185,8 @@ function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
validating: ['验证中', 'Validating'],
|
||||
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { getStatus, listSessions, wsManager } from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
@ -37,6 +38,7 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
|
||||
}
|
||||
|
||||
export function AppRuntimeBridge() {
|
||||
const pathname = usePathname();
|
||||
const sessionId = useChatStore((state) => state.sessionId);
|
||||
const setSessions = useChatStore((state) => state.setSessions);
|
||||
const setWsStatus = useChatStore((state) => state.setWsStatus);
|
||||
@ -45,6 +47,7 @@ export function AppRuntimeBridge() {
|
||||
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
|
||||
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const statusCheckInFlightRef = React.useRef(false);
|
||||
const chatRuntimeEnabled = pathname === '/' || pathname.startsWith('/tasks') || pathname.startsWith('/notifications');
|
||||
|
||||
const loadSessions = React.useCallback(async () => {
|
||||
try {
|
||||
@ -73,15 +76,27 @@ export function AppRuntimeBridge() {
|
||||
}, [setBeaverReady]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!chatRuntimeEnabled) {
|
||||
return;
|
||||
}
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
}, [chatRuntimeEnabled, loadSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!chatRuntimeEnabled) {
|
||||
wsManager.disconnect();
|
||||
setWsStatus('disconnected');
|
||||
setBeaverReady(null);
|
||||
return;
|
||||
}
|
||||
resetProcessState();
|
||||
wsManager.connect(sessionId);
|
||||
}, [resetProcessState, sessionId]);
|
||||
}, [chatRuntimeEnabled, resetProcessState, sessionId, setBeaverReady, setWsStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!chatRuntimeEnabled) {
|
||||
return;
|
||||
}
|
||||
const unsubStatus = wsManager.onStatusChange((status) => {
|
||||
setWsStatus(status);
|
||||
if (status === 'connected') {
|
||||
@ -98,9 +113,12 @@ export function AppRuntimeBridge() {
|
||||
statusCheckCleanupRef.current = null;
|
||||
unsubStatus();
|
||||
};
|
||||
}, [scheduleStatusCheck, setBeaverReady, setWsStatus]);
|
||||
}, [chatRuntimeEnabled, scheduleStatusCheck, setBeaverReady, setWsStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!chatRuntimeEnabled) {
|
||||
return;
|
||||
}
|
||||
const unsubMessage = wsManager.onMessage((data) => {
|
||||
if (isSessionUpdatedEvent(data)) {
|
||||
void loadSessions();
|
||||
@ -115,7 +133,7 @@ export function AppRuntimeBridge() {
|
||||
return () => {
|
||||
unsubMessage();
|
||||
};
|
||||
}, [ingestProcessEvent, loadSessions]);
|
||||
}, [chatRuntimeEnabled, ingestProcessEvent, loadSessions]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export function ChatWorkbench({
|
||||
processArtifacts: ProcessArtifact[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@ -1,324 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
FileJson,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
PanelRightOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { appStatusLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type {
|
||||
SessionProgressArtifactView,
|
||||
SessionProgressStepView,
|
||||
SessionProgressView,
|
||||
} from '@/lib/session-progress';
|
||||
import type { ProcessArtifact, ProcessRunStatus } from '@/types';
|
||||
|
||||
function formatShortTime(value: string, locale: 'zh-CN' | 'en-US') {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function statusTone(status: ProcessRunStatus) {
|
||||
if (status === 'done') return 'text-[#2F8D50] bg-[#E3F1E7] border-[#B8D9C2]';
|
||||
if (status === 'running') return 'text-[#2F6FCA] bg-[#E7EEF9] border-[#B8CBE8]';
|
||||
if (status === 'error') return 'text-[#8A3A2D] bg-[#F0E5E1] border-[#D9BDB4]';
|
||||
if (status === 'cancelled') return 'text-[#6A5E58] bg-[#ECE8E5] border-[#D8D2CE]';
|
||||
return 'text-[#6A5E58] bg-[#F0ECE9] border-[#D8D2CE]';
|
||||
}
|
||||
|
||||
function StepMarker({ step, index }: { step: SessionProgressStepView; index: number }) {
|
||||
if (step.status === 'done') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F8D50] text-white">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (step.status === 'running') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F6FCA] text-[11px] font-semibold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (step.status === 'error') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#8A3A2D] text-white">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#D8D2CE] text-[#6A5E58]">
|
||||
<Circle className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function artifactIcon(type: ProcessArtifact['artifact_type']) {
|
||||
if (type === 'json') return <FileJson className="h-4 w-4" />;
|
||||
if (type === 'image') return <ImageIcon className="h-4 w-4" />;
|
||||
if (type === 'link') return <Link2 className="h-4 w-4" />;
|
||||
if (type === 'markdown' || type === 'text') return <FileText className="h-4 w-4" />;
|
||||
return <FileOutput className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function ProgressHeader({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const percent = view.progress.percent;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4 shadow-[0_8px_24px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#E3F1E7] text-[#2F8D50]">
|
||||
<ListChecks className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm font-semibold text-foreground">{view.title}</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${statusTone(view.status)}`}>
|
||||
{appStatusLabel(view.status, locale)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{pickAppText(locale, '更新于', 'Updated')} {formatShortTime(view.updatedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{view.progress.label}</span>
|
||||
{percent !== null && <span className="font-medium text-foreground">{percent}%</span>}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[#ECE8E5]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#5DB56F] transition-all"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.summary && (
|
||||
<p className="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{view.summary}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StepList({ steps }: { steps: SessionProgressStepView[] }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{pickAppText(locale, '运行步骤', 'Run Steps')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, `${steps.length} 步`, `${steps.length} steps`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.runId} className="grid grid-cols-[24px_1fr] gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<StepMarker step={step} index={index} />
|
||||
{index < steps.length - 1 && <span className="mt-2 h-full min-h-8 w-px bg-[#E6E1DE]" />}
|
||||
</div>
|
||||
<div className="pb-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-2 text-sm font-medium text-foreground">
|
||||
{index + 1}. {step.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{step.actorName} · {formatShortTime(step.updatedAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${statusTone(step.status)}`}>
|
||||
{appStatusLabel(step.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{step.description && (
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
{step.status === 'running' && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-[#2F6FCA]">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>{pickAppText(locale, '正在处理', 'In progress')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ artifact }: { artifact: SessionProgressArtifactView }) {
|
||||
return (
|
||||
<a
|
||||
href={artifact.url || undefined}
|
||||
target={artifact.url ? '_blank' : undefined}
|
||||
rel={artifact.url ? 'noreferrer' : undefined}
|
||||
className="block rounded-lg border border-[#ECE7E3] bg-[#FDFDFC] px-3 py-3 transition-colors hover:bg-[#F7F6F5]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#ECE8E5] text-[#5F5550]">
|
||||
{artifactIcon(artifact.type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{artifact.title}</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{artifact.actorName} · {artifact.typeLabel}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{artifact.preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactSection({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{pickAppText(locale, '生成内容', 'Generated Content')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, `${view.artifacts.length} 个`, `${view.artifacts.length} items`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view.artifactTypeSummaries.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{view.artifactTypeSummaries.map((item) => (
|
||||
<span
|
||||
key={item.type}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[#E6E1DE] bg-[#F7F6F5] px-2.5 py-1 text-xs text-[#4F4642]"
|
||||
>
|
||||
{artifactIcon(item.type)}
|
||||
<span>{item.label}</span>
|
||||
<span className="font-semibold">{item.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '暂时还没有生成内容。', 'No generated content yet.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{view.artifacts.map((artifact) => (
|
||||
<ArtifactRow key={artifact.artifactId} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressPanel({
|
||||
view,
|
||||
onClose,
|
||||
}: {
|
||||
view: SessionProgressView;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[#FBFAF9]">
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b border-[#E6E1DE] px-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{pickAppText(locale, '当前会话的运行进度', 'Current Session Progress')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '任务列表会自动刷新', 'Task updates refresh automatically')}
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-muted-foreground transition-colors hover:bg-[#ECE8E5] hover:text-foreground"
|
||||
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 px-4 py-4">
|
||||
<div className="space-y-4 pb-6">
|
||||
<ProgressHeader view={view} />
|
||||
<StepList steps={view.steps} />
|
||||
<ArtifactSection view={view} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden h-full w-[380px] shrink-0 border-l border-[#E6E1DE] xl:flex">
|
||||
<ProgressPanel view={view} />
|
||||
</aside>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed right-3 top-24 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#342E2B] shadow-[0_8px_22px_rgba(0,0,0,0.16)] transition-colors hover:bg-[#F7F6F5] xl:hidden"
|
||||
aria-label={pickAppText(locale, '查看当前会话运行进度', 'View current session progress')}
|
||||
>
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-50 xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/30"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] border-l border-[#E6E1DE] shadow-2xl">
|
||||
<ProgressPanel view={view} onClose={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, Thumbs
|
||||
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
|
||||
import { getTaskCardMessageIndexes } from '@/lib/chat-messages';
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@ -49,14 +49,19 @@ function MessageBubble({
|
||||
message: ChatMessage;
|
||||
showTaskCard: boolean;
|
||||
canSendFeedback: boolean;
|
||||
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const isUser = message.role === 'user';
|
||||
const textContent = normalizedMessageText(message.content);
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
|
||||
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | null>(null);
|
||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
||||
const validationFailed = message.validation_status === 'failed';
|
||||
const validationDetails =
|
||||
validationFailed
|
||||
? pickAppText(locale, '详细原因会在任务验证区展示;展开任务可查看验证报告。', 'Detailed reasons are shown in the task validation area. Open the task to inspect the validation report.')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
@ -137,14 +142,22 @@ function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isUser && validationFailed && (
|
||||
<details className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<summary className="cursor-pointer text-base font-semibold text-destructive">
|
||||
{pickAppText(locale, '验证失败', 'Validation failed')}
|
||||
</summary>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
|
||||
</details>
|
||||
)}
|
||||
{!isUser && (canSendFeedback || message.feedback_state) && message.run_id && (
|
||||
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
||||
{message.feedback_state ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{message.feedback_state === 'accept' || message.feedback_state === 'satisfied'
|
||||
? pickAppText(locale, '已接受', 'Accepted')
|
||||
{message.feedback_state === 'satisfied'
|
||||
? pickAppText(locale, '已标记满意', 'Marked satisfied')
|
||||
: message.feedback_state === 'revise'
|
||||
? pickAppText(locale, '已请求修改', 'Revision requested')
|
||||
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
|
||||
@ -155,11 +168,11 @@ function MessageBubble({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackMode('accept')}
|
||||
onClick={() => setFeedbackMode('satisfied')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '接受', 'Accept')}
|
||||
{pickAppText(locale, '满意', 'Satisfied')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -209,6 +222,13 @@ function MessageBubble({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{message.validation_status && message.validation_status !== 'unknown' && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.validation_status === 'passed'
|
||||
? pickAppText(locale, '验证通过', 'Validated')
|
||||
: pickAppText(locale, '验证未通过', 'Validation failed')}
|
||||
</span>
|
||||
)}
|
||||
{message.feedback_error && (
|
||||
<span className="text-xs text-destructive">{message.feedback_error}</span>
|
||||
)}
|
||||
@ -244,17 +264,6 @@ function shouldHideSystemAgentMessage(message: ChatMessage): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasRenderableMessageContent(message: ChatMessage): boolean {
|
||||
return hasVisibleChatContent(message);
|
||||
}
|
||||
|
||||
function shouldHideMessage(message: ChatMessage): boolean {
|
||||
if (shouldHideSystemAgentMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
return !shouldDisplayChatMessage(message);
|
||||
}
|
||||
|
||||
function parseTimelineTime(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
@ -333,12 +342,12 @@ export function MessageList({
|
||||
processArtifacts: ProcessArtifact[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const visibleMessages = React.useMemo(
|
||||
() => messages.filter((message) => !shouldHideMessage(message)),
|
||||
() => messages.filter((message) => !shouldHideSystemAgentMessage(message)),
|
||||
[messages]
|
||||
);
|
||||
const teamGroups = React.useMemo(
|
||||
@ -376,21 +385,14 @@ export function MessageList({
|
||||
() => getTaskCardMessageIndexes(visibleMessages),
|
||||
[visibleMessages]
|
||||
);
|
||||
const latestFeedbackMessageIndex = (() => {
|
||||
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = visibleMessages[index];
|
||||
if (
|
||||
message.role === 'assistant'
|
||||
&& message.run_id
|
||||
&& message.task_id
|
||||
&& message.task_status === 'awaiting_acceptance'
|
||||
&& hasRenderableMessageContent(message)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const latestFeedbackRunId = [...visibleMessages]
|
||||
.reverse()
|
||||
.find((message) =>
|
||||
message.role === 'assistant'
|
||||
&& message.run_id
|
||||
&& message.task_id
|
||||
&& message.task_status === 'awaiting_feedback'
|
||||
)?.run_id;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||
@ -409,7 +411,7 @@ export function MessageList({
|
||||
key={item.key}
|
||||
message={item.message}
|
||||
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
|
||||
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex}
|
||||
canSendFeedback={Boolean(latestFeedbackRunId && item.message.run_id === latestFeedbackRunId)}
|
||||
onFeedback={onFeedback}
|
||||
onRequestRevision={onRequestRevision}
|
||||
/>
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
|
||||
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||
|
||||
export type TaskFeedbackItem = {
|
||||
acceptance_type?: unknown;
|
||||
feedback_type?: unknown;
|
||||
comment?: unknown;
|
||||
created_at?: unknown;
|
||||
run_id?: unknown;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
const READY_FOR_ACCEPTANCE_STATUSES = new Set<string>(['awaiting_acceptance', 'needs_revision']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||
if (!runId) return null;
|
||||
return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null;
|
||||
}
|
||||
|
||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||
return [...items].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function feedbackKind(item: TaskFeedbackItem): string {
|
||||
return String(item.acceptance_type || item.feedback_type || '');
|
||||
}
|
||||
|
||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||||
return type || pickAppText(locale, '验收', 'Acceptance');
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['接受', 'Accepted'],
|
||||
satisfied: ['接受', 'Accepted'],
|
||||
revise: ['请求修改', 'Revision requested'],
|
||||
abandon: ['放弃任务', 'Abandoned'],
|
||||
};
|
||||
const label = labels[status];
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
function FeedbackButton({
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
actionBusy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
type: TaskFeedbackType;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
actionBusy: string | null;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceCard({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||||
{isRuntimeStatus(taskStatus) ? (
|
||||
<TaskRuntimeStatusBadge status={taskStatus} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(taskStatus, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskAcceptanceControls
|
||||
sessionId={sessionId}
|
||||
runId={runId}
|
||||
taskStatus={taskStatus}
|
||||
feedbackItems={feedbackItems}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={onRevisionChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceControls({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
|
||||
const trimmedComment = comment.trim();
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{recordedFeedback ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
||||
</div>
|
||||
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
|
||||
{recordedFeedback.created_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : isFinalized ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
||||
</div>
|
||||
) : !isReadyForAcceptance ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务还在执行,完成后才能验收。', 'The task is still running. Acceptance becomes available when a result is ready.')}
|
||||
</div>
|
||||
) : !runId ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<FeedbackButton
|
||||
type="accept"
|
||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '接受', 'Accept')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('accept', trimmedComment || undefined)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="revise"
|
||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit || !trimmedComment}
|
||||
onClick={() => submit('revise', trimmedComment)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="abandon"
|
||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('abandon', trimmedComment || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
<span className="mx-1">·</span>
|
||||
{pickAppText(locale, '会话:', 'Session: ')}
|
||||
<span className="font-mono">{sessionId}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, CheckCircle2, MessageSquare } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
activeLabel: string;
|
||||
durationMs: number | null;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isRuntimeStatus(task.status) ? (
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(task.status, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{showReviewLink ? (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a href={`#${reviewTargetId}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '验收', 'Review')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1>
|
||||
{task.description && task.description !== title ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(durationMs, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, Bot, Download, ExternalLink, FileText, Users } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getFileUrl } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { BackendTask, ProcessArtifact, ProcessRun, TaskTimelineCard } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
runs: ProcessRun[];
|
||||
artifacts: ProcessArtifact[];
|
||||
cards: TaskTimelineCard[];
|
||||
};
|
||||
|
||||
const ACTIVE_RUN_STATUSES = new Set<ProcessRun['status']>(['queued', 'running', 'waiting']);
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['已接受', 'Accepted'],
|
||||
satisfied: ['已接受', 'Accepted'],
|
||||
revise: ['已请求修改', 'Revision requested'],
|
||||
abandon: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function latestFeedback(task: BackendTask): Record<string, unknown> | null {
|
||||
return [...(task.feedback ?? [])].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function acceptanceState(task: BackendTask, locale: 'zh-CN' | 'en-US'): string {
|
||||
const feedback = latestFeedback(task);
|
||||
const kind = String(feedback?.acceptance_type || feedback?.feedback_type || '');
|
||||
if (kind) return humanTaskStatus(kind, locale);
|
||||
if (task.status === 'awaiting_acceptance') return pickAppText(locale, '等待验收', 'Awaiting acceptance');
|
||||
if (task.status === 'needs_revision') return pickAppText(locale, '等待修改', 'Awaiting revision');
|
||||
return pickAppText(locale, '未验收', 'No acceptance yet');
|
||||
}
|
||||
|
||||
function toTime(value: string): number {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function isWarningOrError(card: TaskTimelineCard): boolean {
|
||||
const severity = String(card.details?.severity || card.details?.level || '').toLowerCase();
|
||||
return card.type === 'error' || card.status === 'error' || severity === 'warning' || severity === 'error';
|
||||
}
|
||||
|
||||
function artifactHref(artifact: ProcessArtifact): string | null {
|
||||
if (artifact.url) return artifact.url;
|
||||
if (artifact.file_id) return getFileUrl(artifact.file_id);
|
||||
return null;
|
||||
}
|
||||
|
||||
function inlineArtifactPayload(artifact: ProcessArtifact): { content: string; filename: string; mimeType: string } | null {
|
||||
const baseName = (artifact.title || artifact.artifact_id || 'artifact').replace(/[\\/:*?"<>|]+/g, '-');
|
||||
if (artifact.content !== undefined) {
|
||||
const isMarkdown = artifact.artifact_type === 'markdown';
|
||||
return {
|
||||
content: artifact.content,
|
||||
filename: `${baseName}.${isMarkdown ? 'md' : 'txt'}`,
|
||||
mimeType: isMarkdown ? 'text/markdown;charset=utf-8' : 'text/plain;charset=utf-8',
|
||||
};
|
||||
}
|
||||
if (artifact.data !== undefined) {
|
||||
return {
|
||||
content: JSON.stringify(artifact.data, null, 2),
|
||||
filename: `${baseName}.json`,
|
||||
mimeType: 'application/json;charset=utf-8',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadInlineArtifact(artifact: ProcessArtifact): void {
|
||||
const payload = inlineArtifactPayload(artifact);
|
||||
if (!payload) return;
|
||||
|
||||
const url = URL.createObjectURL(new Blob([payload.content], { type: payload.mimeType }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = payload.filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: ProcessRun }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{run.title || run.actor_name}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{run.actor_name}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(run.started_at, locale)}</div>
|
||||
{run.summary ? <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{run.summary}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const activeRuns = runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status));
|
||||
const childRuns = runs.filter((run) => Boolean(run.parent_run_id));
|
||||
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
|
||||
|
||||
return (
|
||||
<aside className="space-y-4">
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{isRuntimeStatus(task.status) ? (
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(task.status, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '活跃运行', 'Active runs')}: <span className="font-medium text-foreground">{activeRuns.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收', 'Acceptance')}: {acceptanceState(task, locale)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, '运行中', 'Active runs')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{activeRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无活跃运行', 'No active runs')}</p>
|
||||
) : (
|
||||
activeRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{latestAlert ? (
|
||||
<Card className="rounded-md border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
{pickAppText(locale, '最新提醒', 'Latest alert')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-sm font-medium">{latestAlert.title}</div>
|
||||
{latestAlert.summary ? <p className="text-sm text-muted-foreground">{latestAlert.summary}</p> : null}
|
||||
<div className="text-xs text-muted-foreground">{formatTaskRuntimeTime(latestAlert.createdAt, locale)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, 'Agent Team', 'Agent team')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{childRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无子运行', 'No child runs')}</p>
|
||||
) : (
|
||||
childRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, '产物', 'Artifacts')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => {
|
||||
const href = artifactHref(artifact);
|
||||
const inlinePayload = inlineArtifactPayload(artifact);
|
||||
return (
|
||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{artifact.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
|
||||
</div>
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
|
||||
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : inlinePayload ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
|
||||
|
||||
import { TaskTimelineCard, type TaskResultAcceptance } from './TaskTimelineCard';
|
||||
|
||||
type Props = {
|
||||
cards: TaskTimelineCardView[];
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '实时更新', 'Live')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<Card className="rounded-md border-dashed">
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, 'Beaver 正在准备第一步。', 'Beaver is preparing the first step.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cards.map((card) => (
|
||||
<TaskTimelineCard key={card.id} card={card} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,239 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
History,
|
||||
ListChecks,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
ThumbsUp,
|
||||
Users,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||
|
||||
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
|
||||
type Props = {
|
||||
card: TaskTimelineCardView;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export type TaskResultAcceptance = {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function iconForType(type: TaskTimelineCardType) {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return ClipboardList;
|
||||
case 'plan':
|
||||
return ListChecks;
|
||||
case 'skill':
|
||||
return Sparkles;
|
||||
case 'tool_call':
|
||||
return Wrench;
|
||||
case 'tool_result':
|
||||
return TerminalSquare;
|
||||
case 'next_step':
|
||||
return ArrowRightCircle;
|
||||
case 'agent_team':
|
||||
return Users;
|
||||
case 'agent_progress':
|
||||
return Bot;
|
||||
case 'agent_handoff':
|
||||
return GitBranch;
|
||||
case 'artifact':
|
||||
return FileText;
|
||||
case 'error':
|
||||
return AlertTriangle;
|
||||
case 'result':
|
||||
return CheckCircle2;
|
||||
case 'result_history':
|
||||
return History;
|
||||
case 'acceptance':
|
||||
return ThumbsUp;
|
||||
}
|
||||
}
|
||||
|
||||
function detailsJson(details: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
return String(details);
|
||||
}
|
||||
}
|
||||
|
||||
function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<TaskTimelineCardType, [string, string]> = {
|
||||
task_created: ['任务', 'Task'],
|
||||
plan: ['计划', 'Plan'],
|
||||
skill: ['Skill', 'Skill'],
|
||||
tool_call: ['工具调用', 'Tool call'],
|
||||
tool_result: ['工具结果', 'Tool result'],
|
||||
next_step: ['下一步', 'Next step'],
|
||||
agent_team: ['Agent Team', 'Agent team'],
|
||||
agent_progress: ['Agent', 'Agent'],
|
||||
agent_handoff: ['交接', 'Handoff'],
|
||||
artifact: ['产物', 'Artifact'],
|
||||
error: ['异常', 'Error'],
|
||||
result: ['结果', 'Result'],
|
||||
result_history: ['历史结果', 'Result history'],
|
||||
acceptance: ['验收', 'Acceptance'],
|
||||
};
|
||||
const label = labels[type];
|
||||
return pickAppText(locale, label[0], label[1]);
|
||||
}
|
||||
|
||||
function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['接受', 'Accepted'],
|
||||
satisfied: ['接受', 'Accepted'],
|
||||
revise: ['请求修改', 'Revision requested'],
|
||||
abandon: ['放弃任务', 'Abandoned'],
|
||||
warning: ['提醒', 'Warning'],
|
||||
};
|
||||
const label = labels[status];
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
function historyVersions(details: Record<string, unknown> | undefined): Array<Record<string, unknown>> {
|
||||
const versions = details?.versions;
|
||||
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
|
||||
}
|
||||
|
||||
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
|
||||
const status = String(version.acceptanceType || version.status || '');
|
||||
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
|
||||
}
|
||||
|
||||
function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const versions = historyVersions(card.details);
|
||||
|
||||
return (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{versions.map((version, index) => (
|
||||
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{renderHistoryStatus(version, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
|
||||
{version.comment ? (
|
||||
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const Icon = iconForType(card.type);
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold">{card.title}</h3>
|
||||
<Badge variant="secondary" className="shrink-0 text-[11px]">
|
||||
{cardTypeLabel(card.type, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{card.actorName ? <span>{card.actorName}</span> : null}
|
||||
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
||||
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{card.status ? (
|
||||
isRuntimeStatus(card.status) ? (
|
||||
<TaskRuntimeStatusBadge status={card.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="shrink-0 text-[11px]">
|
||||
{humanStatus(card.status, locale)}
|
||||
</Badge>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
|
||||
|
||||
{shouldRenderResultAcceptance ? (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<TaskAcceptanceControls {...resultAcceptance!} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
|
||||
{detailsJson(card.details)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
export { TaskLiveHeader } from './TaskLiveHeader';
|
||||
export { TaskSideRail } from './TaskSideRail';
|
||||
export { TaskTimeline } from './TaskTimeline';
|
||||
export { TaskTimelineCard } from './TaskTimelineCard';
|
||||
@ -4,7 +4,6 @@ import type {
|
||||
AuthzStatus,
|
||||
AuthUser,
|
||||
ActiveTask,
|
||||
AgentConfigPayload,
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
@ -272,7 +271,7 @@ export async function sendMessage(
|
||||
run_id?: string;
|
||||
task_id?: string | null;
|
||||
task_status?: string | null;
|
||||
evidence_status?: string | null;
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
}> {
|
||||
const body: Record<string, unknown> = { message, session_id: sessionId };
|
||||
if (attachments && attachments.length > 0) {
|
||||
@ -294,7 +293,7 @@ export async function sendMessage(
|
||||
finish_reason?: string;
|
||||
task_id?: string | null;
|
||||
task_status?: string | null;
|
||||
evidence_status?: string | null;
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
}>('/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@ -306,29 +305,28 @@ export async function sendMessage(
|
||||
run_id: result.run_id,
|
||||
task_id: result.task_id,
|
||||
task_status: result.task_status,
|
||||
evidence_status: result.evidence_status,
|
||||
validation_result: result.validation_result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitChatFeedback(params: {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
feedbackType: 'accept' | 'revise' | 'abandon';
|
||||
feedbackType: 'satisfied' | 'revise' | 'abandon';
|
||||
comment?: string;
|
||||
}): Promise<{
|
||||
session_id: string;
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
task_status: string;
|
||||
acceptance_type: string;
|
||||
feedback_type: string;
|
||||
}> {
|
||||
return fetchJSON('/api/chat/acceptance', {
|
||||
return fetchJSON('/api/chat/feedback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: params.sessionId,
|
||||
run_id: params.runId,
|
||||
acceptance_type: params.feedbackType,
|
||||
feedback_type: params.feedbackType,
|
||||
comment: params.comment,
|
||||
}),
|
||||
});
|
||||
@ -621,13 +619,6 @@ export async function getStatus(): Promise<SystemStatus> {
|
||||
return fetchJSON('/api/status');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(payload: AgentConfigPayload): Promise<{ ok: boolean }> {
|
||||
return fetchJSON('/api/agent-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProviderConfig(
|
||||
providerId: string,
|
||||
payload: ProviderConfigPayload
|
||||
@ -1372,3 +1363,112 @@ export async function createWorkspaceDir(path: string): Promise<WorkspaceItem> {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User File System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UserFileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number | null;
|
||||
content_type?: string | null;
|
||||
modified?: string | null;
|
||||
}
|
||||
|
||||
export interface UserFileBrowseResult {
|
||||
path: string;
|
||||
items: UserFileItem[];
|
||||
}
|
||||
|
||||
export interface UserFileContent {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
modified: string | null;
|
||||
is_binary: boolean;
|
||||
is_truncated: boolean;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
export interface UserFilesStatus {
|
||||
configured: boolean;
|
||||
storage_mode: string;
|
||||
roots: string[];
|
||||
workspace_visible: boolean;
|
||||
}
|
||||
|
||||
export async function getUserFilesStatus(): Promise<UserFilesStatus> {
|
||||
return fetchJSON('/api/user-files/status');
|
||||
}
|
||||
|
||||
export async function browseUserFiles(path: string = ''): Promise<UserFileBrowseResult> {
|
||||
const params = path ? `?path=${encodeURIComponent(path)}` : '';
|
||||
return fetchJSON(`/api/user-files/browse${params}`);
|
||||
}
|
||||
|
||||
export async function getUserFile(path: string): Promise<UserFileContent> {
|
||||
return fetchJSON(`/api/user-files/preview?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
export function getUserFileDownloadUrl(path: string): string {
|
||||
return buildApiUrl(`/api/user-files/download?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
export async function uploadUserFile(
|
||||
file: File,
|
||||
dirPath: string = 'uploads',
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<UserFileItem> {
|
||||
const locale = getCurrentAppLocale();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', dirPath);
|
||||
|
||||
return new Promise<UserFileItem>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', buildApiUrl('/api/user-files/upload'));
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
let detail = '';
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
detail = typeof data?.detail === 'string' ? data.detail : '';
|
||||
} catch {
|
||||
detail = '';
|
||||
}
|
||||
reject(new Error(detail || `${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUserFile(path: string): Promise<void> {
|
||||
await fetchJSON(`/api/user-files/delete?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserFileDir(path: string): Promise<UserFileItem> {
|
||||
return fetchJSON(`/api/user-files/mkdir?path=${encodeURIComponent(path)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
describe('chat message helpers', () => {
|
||||
@ -46,26 +46,6 @@ describe('chat message helpers', () => {
|
||||
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual(serverMessages);
|
||||
});
|
||||
|
||||
it('merges pending user messages when local state has an unpersisted trailing user turn', () => {
|
||||
const serverMessages: ChatMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Earlier answer',
|
||||
timestamp: '2026-05-21T08:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const localMessages: ChatMessage[] = [
|
||||
...serverMessages,
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Do this long task',
|
||||
timestamp: '2026-05-21T08:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
expect(shouldMergePendingUsers(serverMessages, localMessages, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a task card only on the latest assistant message for the same task', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
@ -85,17 +65,10 @@ describe('chat message helpers', () => {
|
||||
content: 'Final answer.',
|
||||
run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
task_status: 'awaiting_acceptance',
|
||||
task_status: 'awaiting_feedback',
|
||||
},
|
||||
];
|
||||
|
||||
expect(Array.from(getTaskCardMessageIndexes(messages))).toEqual([2]);
|
||||
});
|
||||
|
||||
it('hides empty assistant records from session history', () => {
|
||||
expect(shouldDisplayChatMessage({ role: 'assistant', content: '', task_id: 'task-1', run_id: 'run-1' })).toBe(false);
|
||||
expect(shouldDisplayChatMessage({ role: 'assistant', content: '\u200B\uFEFF', task_id: 'task-1', run_id: 'run-1' })).toBe(false);
|
||||
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
|
||||
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,28 +1,5 @@
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
|
||||
|
||||
export function normalizedMessageText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content.replace(INVISIBLE_CONTENT_CHARS, '').trim();
|
||||
}
|
||||
if (content == null) {
|
||||
return '';
|
||||
}
|
||||
return String(content).replace(INVISIBLE_CONTENT_CHARS, '').trim();
|
||||
}
|
||||
|
||||
export function hasVisibleChatContent(msg: ChatMessage): boolean {
|
||||
if (normalizedMessageText(msg.content)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(msg.attachments?.length);
|
||||
}
|
||||
|
||||
export function shouldDisplayChatMessage(msg: ChatMessage): boolean {
|
||||
return msg.role !== 'assistant' || hasVisibleChatContent(msg);
|
||||
}
|
||||
|
||||
export function messageFingerprint(msg: ChatMessage): string {
|
||||
const attachmentKey = (msg.attachments ?? [])
|
||||
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
|
||||
@ -53,42 +30,6 @@ export function mergeServerWithPendingUsers(serverMessages: ChatMessage[], local
|
||||
return [...serverMessages, ...pendingUsers];
|
||||
}
|
||||
|
||||
export function shouldMergePendingUsers(
|
||||
serverMessages: ChatMessage[],
|
||||
localMessages: ChatMessage[],
|
||||
waitingForReply: boolean
|
||||
): boolean {
|
||||
if (waitingForReply) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastLocal = localMessages[localMessages.length - 1];
|
||||
if (lastLocal?.role !== 'user') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const message of serverMessages) {
|
||||
const key = messageFingerprint(message);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const message of localMessages) {
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
const key = messageFingerprint(message);
|
||||
const count = counts.get(key) ?? 0;
|
||||
if (count > 0) {
|
||||
counts.set(key, count - 1);
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTaskCardMessageIndexes(messages: ChatMessage[]): Set<number> {
|
||||
const latestByTask = new Map<string, number>();
|
||||
|
||||
|
||||
@ -1,201 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
describe('session progress view builder', () => {
|
||||
it('selects the latest active root run for the current session and builds its run tree', () => {
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'old-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '旧任务',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T08:00:00.000Z',
|
||||
finished_at: '2026-05-22T08:05:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'latest-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '销售数据分析报告生成',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:00:00.000Z',
|
||||
metadata: {
|
||||
step_index: 3,
|
||||
step_total: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
run_id: 'collect-data',
|
||||
parent_run_id: 'latest-root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'collector',
|
||||
actor_name: 'Data Agent',
|
||||
title: '收集销售数据',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T09:01:00.000Z',
|
||||
finished_at: '2026-05-22T09:03:00.000Z',
|
||||
summary: '已获取 Q1 销售数据',
|
||||
},
|
||||
{
|
||||
run_id: 'clean-data',
|
||||
parent_run_id: 'latest-root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
title: '数据清洗与预处理',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:04:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'other-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:other',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '其他会话任务',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-clean',
|
||||
run_id: 'clean-data',
|
||||
parent_run_id: 'latest-root',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
text: '清洗缺失值、异常值,统一格式',
|
||||
created_at: '2026-05-22T09:05:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-json',
|
||||
run_id: 'collect-data',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'collector',
|
||||
actor_name: 'Data Agent',
|
||||
title: '销售数据',
|
||||
artifact_type: 'json',
|
||||
data: { rows: 120 },
|
||||
created_at: '2026-05-22T09:03:30.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'artifact-markdown',
|
||||
run_id: 'clean-data',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
title: '清洗说明',
|
||||
artifact_type: 'markdown',
|
||||
content: '已完成数据标准化。',
|
||||
created_at: '2026-05-22T09:05:30.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'artifact-other-session',
|
||||
run_id: 'other-root',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
title: '其他会话产物',
|
||||
artifact_type: 'text',
|
||||
content: '不应出现',
|
||||
created_at: '2026-05-22T10:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildSessionProgressView({
|
||||
sessionId: 'web:current',
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(view).not.toBeNull();
|
||||
expect(view?.rootRunId).toBe('latest-root');
|
||||
expect(view?.title).toBe('销售数据分析报告生成');
|
||||
expect(view?.progress).toMatchObject({
|
||||
value: 3,
|
||||
max: 5,
|
||||
percent: 60,
|
||||
label: '运行中:3 / 5 步',
|
||||
});
|
||||
expect(view?.steps.map((step) => step.runId)).toEqual(['collect-data', 'clean-data', 'latest-root']);
|
||||
expect(view?.steps.find((step) => step.runId === 'clean-data')?.description).toBe('清洗缺失值、异常值,统一格式');
|
||||
expect(view?.artifactTypeSummaries).toEqual([
|
||||
{ type: 'json', count: 1, label: 'JSON' },
|
||||
{ type: 'markdown', count: 1, label: 'Markdown' },
|
||||
]);
|
||||
expect(view?.artifacts.map((artifact) => artifact.artifactId)).toEqual(['artifact-markdown', 'artifact-json']);
|
||||
});
|
||||
|
||||
it('falls back to completed child run counts when no explicit progress metadata exists', () => {
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '生成总结',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'done-child',
|
||||
parent_run_id: 'root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'writer',
|
||||
actor_name: 'Writer',
|
||||
title: '整理结果',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T09:01:00.000Z',
|
||||
finished_at: '2026-05-22T09:02:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'running-child',
|
||||
parent_run_id: 'root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'reviewer',
|
||||
actor_name: 'Reviewer',
|
||||
title: '复核结果',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:03:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildSessionProgressView({
|
||||
sessionId: 'web:current',
|
||||
processRuns,
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(view?.progress).toMatchObject({
|
||||
value: 1,
|
||||
max: 2,
|
||||
percent: 50,
|
||||
label: '已完成 1 / 2 步',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,392 +0,0 @@
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||
|
||||
const TERMINAL_STATUSES = new Set<ProcessRunStatus>(['done', 'error', 'cancelled']);
|
||||
const ACTIVE_STATUSES = new Set<ProcessRunStatus>(['queued', 'running', 'waiting']);
|
||||
const ARTIFACT_TYPE_ORDER: ProcessArtifact['artifact_type'][] = [
|
||||
'text',
|
||||
'json',
|
||||
'file',
|
||||
'image',
|
||||
'link',
|
||||
'markdown',
|
||||
];
|
||||
|
||||
export interface SessionProgressValueView {
|
||||
label: string;
|
||||
value: number | null;
|
||||
max: number | null;
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
export interface SessionProgressStepView {
|
||||
runId: string;
|
||||
title: string;
|
||||
actorName: string;
|
||||
status: ProcessRunStatus;
|
||||
description: string | null;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt: string | null;
|
||||
artifactCount: number;
|
||||
isRoot: boolean;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface SessionProgressArtifactView {
|
||||
artifactId: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
type: ProcessArtifact['artifact_type'];
|
||||
typeLabel: string;
|
||||
actorName: string;
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface SessionProgressArtifactTypeSummary {
|
||||
type: ProcessArtifact['artifact_type'];
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SessionProgressView {
|
||||
rootRunId: string;
|
||||
title: string;
|
||||
status: ProcessRunStatus;
|
||||
summary: string | null;
|
||||
updatedAt: string;
|
||||
progress: SessionProgressValueView;
|
||||
steps: SessionProgressStepView[];
|
||||
artifacts: SessionProgressArtifactView[];
|
||||
artifactTypeSummaries: SessionProgressArtifactTypeSummary[];
|
||||
}
|
||||
|
||||
export type BuildSessionProgressInput = {
|
||||
sessionId: string;
|
||||
processRuns: ProcessRun[];
|
||||
processEvents: ProcessEvent[];
|
||||
processArtifacts: ProcessArtifact[];
|
||||
locale?: AppLocale;
|
||||
};
|
||||
|
||||
function toTime(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function latestTimestamp(values: Array<string | null | undefined>): string | null {
|
||||
let selected: string | null = null;
|
||||
let selectedTime = -1;
|
||||
for (const value of values) {
|
||||
const time = toTime(value);
|
||||
if (time === null || time <= selectedTime) continue;
|
||||
selected = value ?? null;
|
||||
selectedTime = time;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function compareIsoDesc(a?: string | null, b?: string | null): number {
|
||||
return (toTime(b) ?? 0) - (toTime(a) ?? 0);
|
||||
}
|
||||
|
||||
function firstNumber(metadata: Record<string, unknown> | undefined, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata?.[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]> {
|
||||
const map = new Map<string, ProcessRun[]>();
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
const children = map.get(run.parent_run_id);
|
||||
if (children) {
|
||||
children.push(run);
|
||||
} else {
|
||||
map.set(run.parent_run_id, [run]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
|
||||
const collected: ProcessRun[] = [];
|
||||
const stack = [rootRun];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || seen.has(current.run_id)) continue;
|
||||
seen.add(current.run_id);
|
||||
collected.push(current);
|
||||
const children = childrenMap.get(current.run_id) ?? [];
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
stack.push(children[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function groupByRunId<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.run_id);
|
||||
if (existing) {
|
||||
existing.push(item);
|
||||
} else {
|
||||
map.set(item.run_id, [item]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getRunUpdatedAt(
|
||||
run: ProcessRun,
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): string {
|
||||
return (
|
||||
latestTimestamp([
|
||||
run.finished_at,
|
||||
run.started_at,
|
||||
...(eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at),
|
||||
...(artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at),
|
||||
]) ?? run.started_at
|
||||
);
|
||||
}
|
||||
|
||||
function getTreeUpdatedAt(
|
||||
runs: ProcessRun[],
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): string {
|
||||
return latestTimestamp(runs.map((run) => getRunUpdatedAt(run, eventsByRun, artifactsByRun))) ?? runs[0]?.started_at ?? '';
|
||||
}
|
||||
|
||||
function latestEventText(events: ProcessEvent[]): string | null {
|
||||
const event = [...events]
|
||||
.filter((item) => item.text?.trim())
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))[0];
|
||||
return event?.text?.trim() || null;
|
||||
}
|
||||
|
||||
function percent(value: number, max: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
||||
}
|
||||
|
||||
function explicitProgress(
|
||||
rootRun: ProcessRun,
|
||||
treeEvents: ProcessEvent[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressValueView | null {
|
||||
const metadataSources = [
|
||||
rootRun.metadata,
|
||||
...[...treeEvents]
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||
.map((event) => event.metadata),
|
||||
];
|
||||
|
||||
for (const metadata of metadataSources) {
|
||||
const stepValue = firstNumber(metadata, ['step_index']);
|
||||
const stepMax = firstNumber(metadata, ['step_total']);
|
||||
if (stepValue !== null && stepMax !== null && stepMax > 0) {
|
||||
const safeValue = Math.min(stepValue, stepMax);
|
||||
return {
|
||||
label: pickAppText(locale, `运行中:${safeValue} / ${stepMax} 步`, `Running: ${safeValue} / ${stepMax} steps`),
|
||||
value: safeValue,
|
||||
max: stepMax,
|
||||
percent: percent(safeValue, stepMax),
|
||||
};
|
||||
}
|
||||
|
||||
const stageValue = firstNumber(metadata, ['stage_index', 'phase_index']);
|
||||
const stageMax = firstNumber(metadata, ['stage_total', 'phase_total']);
|
||||
if (stageValue !== null && stageMax !== null && stageMax > 0) {
|
||||
const safeValue = Math.min(stageValue, stageMax);
|
||||
return {
|
||||
label: pickAppText(locale, `运行中:${safeValue} / ${stageMax} 阶段`, `Running: ${safeValue} / ${stageMax} stages`),
|
||||
value: safeValue,
|
||||
max: stageMax,
|
||||
percent: percent(safeValue, stageMax),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackProgress(taskRuns: ProcessRun[], locale: AppLocale): SessionProgressValueView {
|
||||
const childRuns = taskRuns.filter((run) => run.parent_run_id);
|
||||
const runsForProgress = childRuns.length > 0 ? childRuns : taskRuns;
|
||||
const doneRuns = runsForProgress.filter((run) => run.status === 'done').length;
|
||||
const totalRuns = runsForProgress.length;
|
||||
|
||||
if (totalRuns > 0) {
|
||||
return {
|
||||
label: pickAppText(locale, `已完成 ${doneRuns} / ${totalRuns} 步`, `Completed ${doneRuns} / ${totalRuns} steps`),
|
||||
value: doneRuns,
|
||||
max: totalRuns,
|
||||
percent: percent(doneRuns, totalRuns),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
|
||||
value: null,
|
||||
max: null,
|
||||
percent: null,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactTypeLabel(type: ProcessArtifact['artifact_type'], locale: AppLocale): string {
|
||||
if (type === 'text') return pickAppText(locale, '文本', 'Text');
|
||||
if (type === 'json') return 'JSON';
|
||||
if (type === 'file') return pickAppText(locale, '文件', 'File');
|
||||
if (type === 'image') return pickAppText(locale, '图片', 'Image');
|
||||
if (type === 'link') return pickAppText(locale, '链接', 'Link');
|
||||
return 'Markdown';
|
||||
}
|
||||
|
||||
function artifactPreview(artifact: ProcessArtifact, locale: AppLocale): string {
|
||||
if (artifact.content?.trim()) {
|
||||
return artifact.content.trim().replace(/\s+/g, ' ').slice(0, 120);
|
||||
}
|
||||
if (artifact.url?.trim()) return artifact.url.trim();
|
||||
if (artifact.data !== undefined) {
|
||||
return JSON.stringify(artifact.data).slice(0, 120);
|
||||
}
|
||||
return pickAppText(locale, '暂无预览', 'No preview');
|
||||
}
|
||||
|
||||
function buildArtifactSummaries(
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressArtifactTypeSummary[] {
|
||||
const counts = new Map<ProcessArtifact['artifact_type'], number>();
|
||||
for (const artifact of artifacts) {
|
||||
counts.set(artifact.artifact_type, (counts.get(artifact.artifact_type) ?? 0) + 1);
|
||||
}
|
||||
return ARTIFACT_TYPE_ORDER
|
||||
.filter((type) => counts.has(type))
|
||||
.map((type) => ({
|
||||
type,
|
||||
count: counts.get(type) ?? 0,
|
||||
label: artifactTypeLabel(type, locale),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildArtifactViews(
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressArtifactView[] {
|
||||
return [...artifacts]
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||
.map((artifact) => ({
|
||||
artifactId: artifact.artifact_id,
|
||||
runId: artifact.run_id,
|
||||
title: artifact.title,
|
||||
type: artifact.artifact_type,
|
||||
typeLabel: artifactTypeLabel(artifact.artifact_type, locale),
|
||||
actorName: artifact.actor_name || artifact.actor_id,
|
||||
preview: artifactPreview(artifact, locale),
|
||||
createdAt: artifact.created_at,
|
||||
url: artifact.url,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSteps(
|
||||
rootRun: ProcessRun,
|
||||
taskRuns: ProcessRun[],
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): SessionProgressStepView[] {
|
||||
return [...taskRuns]
|
||||
.sort((a, b) => {
|
||||
if (a.run_id === rootRun.run_id) return 1;
|
||||
if (b.run_id === rootRun.run_id) return -1;
|
||||
return (toTime(a.started_at) ?? 0) - (toTime(b.started_at) ?? 0);
|
||||
})
|
||||
.map((run) => {
|
||||
const runEvents = eventsByRun.get(run.run_id) ?? [];
|
||||
const runArtifacts = artifactsByRun.get(run.run_id) ?? [];
|
||||
return {
|
||||
runId: run.run_id,
|
||||
title: run.title,
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
description: latestEventText(runEvents) || run.summary?.trim() || null,
|
||||
startedAt: run.started_at,
|
||||
updatedAt: getRunUpdatedAt(run, eventsByRun, artifactsByRun),
|
||||
finishedAt: run.finished_at ?? null,
|
||||
artifactCount: runArtifacts.length,
|
||||
isRoot: run.run_id === rootRun.run_id,
|
||||
isCurrent: !TERMINAL_STATUSES.has(run.status),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSessionProgressView({
|
||||
sessionId,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale = getCurrentAppLocale(),
|
||||
}: BuildSessionProgressInput): SessionProgressView | null {
|
||||
const sessionRuns = processRuns.filter((run) => run.session_id === sessionId);
|
||||
const rootRuns = sessionRuns.filter((run) => !run.parent_run_id);
|
||||
if (rootRuns.length === 0) return null;
|
||||
|
||||
const allChildrenMap = buildChildrenMap(processRuns);
|
||||
const runTreeCache = new Map<string, ProcessRun[]>();
|
||||
const treeForRoot = (root: ProcessRun) => {
|
||||
const cached = runTreeCache.get(root.run_id);
|
||||
if (cached) return cached;
|
||||
const tree = collectRunTree(root, allChildrenMap).filter(
|
||||
(run) => run.session_id === sessionId || run.run_id === root.run_id
|
||||
);
|
||||
runTreeCache.set(root.run_id, tree);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const allEventsByRun = groupByRunId(processEvents);
|
||||
const allArtifactsByRun = groupByRunId(processArtifacts);
|
||||
const selectedRoot = [...rootRuns].sort((a, b) => {
|
||||
const aActive = ACTIVE_STATUSES.has(a.status);
|
||||
const bActive = ACTIVE_STATUSES.has(b.status);
|
||||
if (aActive !== bActive) return aActive ? -1 : 1;
|
||||
return compareIsoDesc(
|
||||
getTreeUpdatedAt(treeForRoot(a), allEventsByRun, allArtifactsByRun),
|
||||
getTreeUpdatedAt(treeForRoot(b), allEventsByRun, allArtifactsByRun)
|
||||
);
|
||||
})[0];
|
||||
|
||||
if (!selectedRoot) return null;
|
||||
|
||||
const taskRuns = treeForRoot(selectedRoot);
|
||||
const taskRunIds = new Set(taskRuns.map((run) => run.run_id));
|
||||
const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id));
|
||||
const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id));
|
||||
const eventsByRun = groupByRunId(taskEvents);
|
||||
const artifactsByRun = groupByRunId(taskArtifacts);
|
||||
const updatedAt = getTreeUpdatedAt(taskRuns, eventsByRun, artifactsByRun);
|
||||
const progress = explicitProgress(selectedRoot, taskEvents, locale) ?? fallbackProgress(taskRuns, locale);
|
||||
|
||||
return {
|
||||
rootRunId: selectedRoot.run_id,
|
||||
title: selectedRoot.title,
|
||||
status: selectedRoot.status,
|
||||
summary: selectedRoot.summary?.trim() || latestEventText(eventsByRun.get(selectedRoot.run_id) ?? []) || null,
|
||||
updatedAt,
|
||||
progress,
|
||||
steps: buildSteps(selectedRoot, taskRuns, eventsByRun, artifactsByRun),
|
||||
artifacts: buildArtifactViews(taskArtifacts, locale),
|
||||
artifactTypeSummaries: buildArtifactSummaries(taskArtifacts, locale),
|
||||
};
|
||||
}
|
||||
@ -6,7 +6,6 @@ describe('chat store process event ingestion', () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({
|
||||
sessionId: 'web:alpha',
|
||||
inputDrafts: {},
|
||||
processRuns: [],
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
@ -19,7 +18,6 @@ describe('chat store process event ingestion', () => {
|
||||
afterEach(() => {
|
||||
useChatStore.setState({
|
||||
sessionId: 'web:default',
|
||||
inputDrafts: {},
|
||||
processRuns: [],
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
@ -51,42 +49,4 @@ describe('chat store process event ingestion', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores input drafts per session', () => {
|
||||
useChatStore.getState().setInputDraft('web:alpha', 'message for alpha');
|
||||
useChatStore.getState().setInputDraft('web:beta', 'message for beta');
|
||||
|
||||
expect(useChatStore.getState().getInputDraft('web:alpha')).toBe('message for alpha');
|
||||
expect(useChatStore.getState().getInputDraft('web:beta')).toBe('message for beta');
|
||||
|
||||
useChatStore.getState().clearInputDraft('web:alpha');
|
||||
|
||||
expect(useChatStore.getState().getInputDraft('web:alpha')).toBe('');
|
||||
expect(useChatStore.getState().getInputDraft('web:beta')).toBe('message for beta');
|
||||
});
|
||||
|
||||
it('keeps live task events after persisted session projection is merged', () => {
|
||||
const store = useChatStore.getState();
|
||||
store.setSessionId('web:default');
|
||||
store.ingestProcessEvent({
|
||||
type: 'process_run_progress',
|
||||
session_id: 'web:default',
|
||||
run_id: 'run-live',
|
||||
parent_run_id: null,
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: '正在调用工具',
|
||||
metadata: { task_id: 'task-live', timeline_type: 'tool_call' },
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
});
|
||||
|
||||
store.setSessionProcess('web:default', {
|
||||
runs: [],
|
||||
events: [],
|
||||
artifacts: [],
|
||||
});
|
||||
|
||||
expect(useChatStore.getState().processEvents.some((event) => event.run_id === 'run-live')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -36,7 +36,6 @@ interface ChatStore {
|
||||
isAuthLoading: boolean;
|
||||
sessionId: string;
|
||||
messages: ChatMessage[];
|
||||
inputDrafts: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
streamingContent: string;
|
||||
wsStatus: WsStatus;
|
||||
@ -57,9 +56,6 @@ interface ChatStore {
|
||||
setSessionId: (id: string) => void;
|
||||
setMessages: (msgs: ChatMessage[]) => void;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
setInputDraft: (sessionId: string, value: string) => void;
|
||||
getInputDraft: (sessionId: string) => string;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
updateMessageFeedback: (
|
||||
runId: string,
|
||||
feedbackState: ChatMessage['feedback_state'],
|
||||
@ -117,11 +113,6 @@ function appendEvent(collection: ProcessEvent[], event: ProcessEvent): ProcessEv
|
||||
return [...collection, event];
|
||||
}
|
||||
|
||||
function hasTaskMetadata(item: { metadata?: Record<string, unknown> }): boolean {
|
||||
const taskId = item.metadata?.task_id;
|
||||
return typeof taskId === 'string' && taskId.trim().length > 0;
|
||||
}
|
||||
|
||||
function createEventId(event: ProcessWsEvent): string {
|
||||
if (event.type === 'process_cancel_ack') {
|
||||
return `${event.type}:${event.run_id}`;
|
||||
@ -135,12 +126,11 @@ function createEventId(event: ProcessWsEvent): string {
|
||||
return `${event.type}:${event.run_id}:${event.created_at}:${suffix}`;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
user: null,
|
||||
isAuthLoading: true,
|
||||
sessionId: getInitialSessionId(),
|
||||
messages: [],
|
||||
inputDrafts: {},
|
||||
isLoading: false,
|
||||
streamingContent: '',
|
||||
wsStatus: 'disconnected',
|
||||
@ -165,23 +155,6 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
},
|
||||
setMessages: (msgs) => set({ messages: msgs }),
|
||||
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
|
||||
setInputDraft: (sessionId, value) =>
|
||||
set((state) => ({
|
||||
inputDrafts: {
|
||||
...state.inputDrafts,
|
||||
[sessionId]: value,
|
||||
},
|
||||
})),
|
||||
getInputDraft: (sessionId) => get().inputDrafts[sessionId] ?? '',
|
||||
clearInputDraft: (sessionId) =>
|
||||
set((state) => {
|
||||
if (!(sessionId in state.inputDrafts)) {
|
||||
return {};
|
||||
}
|
||||
const nextDrafts = { ...state.inputDrafts };
|
||||
delete nextDrafts[sessionId];
|
||||
return { inputDrafts: nextDrafts };
|
||||
}),
|
||||
updateMessageFeedback: (runId, feedbackState, error) =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((message) =>
|
||||
@ -398,11 +371,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const incomingArtifacts = projection.artifacts || [];
|
||||
const incomingRunIds = new Set(incomingRuns.map((run) => run.run_id));
|
||||
const nextRuns = [
|
||||
...state.processRuns.filter((run) => {
|
||||
if (incomingRunIds.has(run.run_id)) return false;
|
||||
if (run.session_id !== sessionId) return true;
|
||||
return hasTaskMetadata(run);
|
||||
}),
|
||||
...state.processRuns.filter((run) => run.session_id !== sessionId && !incomingRunIds.has(run.run_id)),
|
||||
...incomingRuns,
|
||||
];
|
||||
const liveRunIds = new Set(nextRuns.map((run) => run.run_id));
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const baseTask: BackendTask = {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:test',
|
||||
description: '查找餐厅',
|
||||
goal: '查找餐厅',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'main-agent',
|
||||
created_at: '2026-05-27T02:02:41.000Z',
|
||||
updated_at: '2026-05-27T02:02:41.500Z',
|
||||
run_ids: [],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
describe('task detail refresh helpers', () => {
|
||||
it('polls executing task details regardless of websocket status', () => {
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'running' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'open' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'awaiting_acceptance' })).toBe(false);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'closed' })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses current time for active task duration instead of stale updated_at', () => {
|
||||
vi.setSystemTime(new Date('2026-05-27T02:03:41.000Z'));
|
||||
|
||||
expect(taskDetailDurationMs(baseTask)).toBe(60_000);
|
||||
expect(taskDetailDurationMs({ ...baseTask, status: 'awaiting_acceptance', updated_at: '2026-05-27T02:10:55.000Z' })).toBe(494_000);
|
||||
});
|
||||
});
|
||||
@ -1,18 +0,0 @@
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const EXECUTING_TASK_STATUSES = new Set(['open', 'queued', 'running']);
|
||||
const FINISHED_FOR_DURATION_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
export function shouldPollTaskDetail(task: Pick<BackendTask, 'status'> | null): boolean {
|
||||
return Boolean(task && EXECUTING_TASK_STATUSES.has(task.status));
|
||||
}
|
||||
|
||||
export function taskDetailDurationMs(task: Pick<BackendTask, 'created_at' | 'updated_at' | 'closed_at' | 'status'>): number | null {
|
||||
const start = new Date(task.created_at).getTime();
|
||||
const end = FINISHED_FOR_DURATION_STATUSES.has(task.status)
|
||||
? new Date(task.closed_at || task.updated_at).getTime()
|
||||
: Date.now();
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
return Math.max(0, end - start);
|
||||
}
|
||||
@ -1,469 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
function makeTask(overrides: Partial<BackendTask> = {}): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Research the market',
|
||||
short_title: 'Market research',
|
||||
is_open: true,
|
||||
goal: 'Summarize the market',
|
||||
constraints: [],
|
||||
priority: 1,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
updated_at: '2026-05-26T10:00:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTaskTimelineCards', () => {
|
||||
it('builds ordered timeline cards from task process data', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Plan and coordinate',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:00:30.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Read source documents',
|
||||
status: 'done',
|
||||
started_at: '2026-05-26T10:05:00.000Z',
|
||||
finished_at: '2026-05-26T10:05:30.000Z',
|
||||
summary: 'Finished reading source documents.',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-plan',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-skill',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'skill_selected',
|
||||
actor_type: 'system',
|
||||
actor_id: 'skill-router',
|
||||
actor_name: 'Skill Router',
|
||||
text: 'Research skill selected.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
selected_skill_names: ['research'],
|
||||
reason: 'Need source review.',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Documents read.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: '2 documents read successfully.',
|
||||
},
|
||||
},
|
||||
];
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-summary',
|
||||
run_id: 'run-research',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Research summary',
|
||||
artifact_type: 'markdown',
|
||||
content: '# Summary',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({
|
||||
task,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
});
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'agent_progress',
|
||||
'artifact',
|
||||
]);
|
||||
expect(cards[1].title).toBe('执行计划');
|
||||
expect(cards[2].title).toBe('选择 Skill');
|
||||
expect(cards[4].summary).toBe('2 documents read successfully.');
|
||||
expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']);
|
||||
});
|
||||
|
||||
it('appends result and acceptance cards for closed tasks with feedback', () => {
|
||||
const task = makeTask({
|
||||
is_open: false,
|
||||
status: 'closed',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
closed_at: '2026-05-26T10:04:00.000Z',
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'accept',
|
||||
comment: '可以',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
run_id: 'run-main',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cards = buildTaskTimelineCards({ task });
|
||||
|
||||
expect(cards.at(-2)?.type).toBe('result');
|
||||
expect(cards.at(-1)?.type).toBe('acceptance');
|
||||
expect(cards.at(-1)?.summary).toContain('可以');
|
||||
});
|
||||
|
||||
it('uses the latest assistant message from the acceptance run as the result body', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Draft answer', created_at: '2026-05-26T10:03:00.000Z' },
|
||||
{ role: 'assistant', content: 'Final user-visible answer', created_at: '2026-05-26T10:04:00.000Z' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'The task result is ready for user acceptance.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: 'Summary should not replace the final answer.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
const result = cards.find((card) => card.type === 'result');
|
||||
|
||||
expect(result?.summary).toBe('Final user-visible answer');
|
||||
expect(result?.details?.result_summary).toBe('Summary should not replace the final answer.');
|
||||
});
|
||||
|
||||
it('collapses previous result and acceptance cards into a history pack', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:12:00.000Z',
|
||||
run_ids: ['run-1', 'run-2'],
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'revise',
|
||||
comment: 'Add decisions',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
run_id: 'run-1',
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-1',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version one answer', created_at: '2026-05-26T10:05:00.000Z' }],
|
||||
},
|
||||
{
|
||||
run_id: 'run-2',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version two answer', created_at: '2026-05-26T10:12:00.000Z' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-1',
|
||||
run_id: 'run-1',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result one ready.',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-plan-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Task Planner',
|
||||
text: 'Second attempt planned.',
|
||||
created_at: '2026-05-26T10:08:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-result-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result two ready.',
|
||||
created_at: '2026-05-26T10:12:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'result_history',
|
||||
'plan',
|
||||
'result',
|
||||
]);
|
||||
const history = cards.find((card) => card.type === 'result_history');
|
||||
expect(history?.summary).toBe('1 历史结果版本');
|
||||
expect(history?.details?.versions).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
result: 'Version one answer',
|
||||
acceptanceType: 'revise',
|
||||
comment: 'Add decisions',
|
||||
}),
|
||||
]);
|
||||
expect(cards.find((card) => card.id === 'evt-plan-2')).toBeTruthy();
|
||||
expect(cards.at(-1)?.summary).toBe('Version two answer');
|
||||
});
|
||||
|
||||
it('does not add fallback progress when a child run already has progress events', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Read source documents',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-progress',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processRuns, processEvents });
|
||||
|
||||
expect(cards.filter((card) => card.runId === 'run-research' && card.type === 'agent_progress')).toHaveLength(1);
|
||||
expect(cards.map((card) => card.id)).not.toContain('run-research:fallback-progress');
|
||||
});
|
||||
|
||||
it('marks a tool call as finished when a matching tool result exists', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Calling tool: web_search.',
|
||||
status: 'running',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Search failed.',
|
||||
status: 'error',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
result_summary: 'Search failed.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-tool-start')?.status).toBe('error');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.type).toBe('tool_result');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.summary).toBe('Search failed.');
|
||||
});
|
||||
|
||||
it('maps agent_finished events without timeline metadata to agent progress cards', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-agent-finished',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'agent_finished',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
text: 'Finished reading source documents.',
|
||||
status: 'done',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-agent-finished')?.type).toBe('agent_progress');
|
||||
});
|
||||
|
||||
it('sorts invalid timestamps after valid timestamps while preserving insertion order', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-invalid-date',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: 'not-a-date',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.id)).toEqual(['task-1:created', 'evt-invalid-date']);
|
||||
});
|
||||
|
||||
it('dedupes synthetic result and acceptance milestones when lifecycle events exist', () => {
|
||||
const task = makeTask({
|
||||
is_open: false,
|
||||
status: 'closed',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
closed_at: '2026-05-26T10:04:00.000Z',
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'accept',
|
||||
comment: '可以',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
run_id: 'run-main',
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Result is ready.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-acceptance-recorded',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_acceptance_recorded',
|
||||
actor_type: 'user',
|
||||
actor_id: 'user-acceptance',
|
||||
actor_name: 'User Acceptance',
|
||||
text: '可以',
|
||||
created_at: '2026-05-26T10:05:02.000Z',
|
||||
metadata: {
|
||||
acceptance_type: 'accept',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.filter((card) => card.type === 'result')).toHaveLength(1);
|
||||
expect(cards.filter((card) => card.type === 'acceptance')).toHaveLength(1);
|
||||
expect(cards.map((card) => card.id)).toContain('evt-result-ready');
|
||||
expect(cards.map((card) => card.id)).toContain('evt-acceptance-recorded');
|
||||
});
|
||||
});
|
||||
@ -1,490 +0,0 @@
|
||||
import type {
|
||||
BackendTask,
|
||||
ProcessArtifact,
|
||||
ProcessEvent,
|
||||
ProcessRun,
|
||||
TaskTimelineCard,
|
||||
TaskTimelineCardType,
|
||||
} from '@/types';
|
||||
|
||||
export type BuildTaskTimelineCardsInput = {
|
||||
task: BackendTask;
|
||||
processRuns?: ProcessRun[];
|
||||
processEvents?: ProcessEvent[];
|
||||
processArtifacts?: ProcessArtifact[];
|
||||
};
|
||||
|
||||
const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'next_step',
|
||||
'agent_team',
|
||||
'agent_progress',
|
||||
'agent_handoff',
|
||||
'artifact',
|
||||
'error',
|
||||
'result',
|
||||
'result_history',
|
||||
'acceptance',
|
||||
]);
|
||||
|
||||
const RESULT_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
function isTimelineCardType(value: unknown): value is TaskTimelineCardType {
|
||||
return typeof value === 'string' && TIMELINE_CARD_TYPES.has(value as TaskTimelineCardType);
|
||||
}
|
||||
|
||||
function toTime(value: string): number | null {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeSkillNames(metadata: Record<string, unknown> | undefined): string[] | undefined {
|
||||
if (!metadata || (!('skill_names' in metadata) && !('selected_skill_names' in metadata))) {
|
||||
return undefined;
|
||||
}
|
||||
const names = [
|
||||
...stringList(metadata.skill_names),
|
||||
...stringList(metadata.selected_skill_names),
|
||||
];
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
|
||||
function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
|
||||
const timelineType = event.metadata?.timeline_type;
|
||||
if (isTimelineCardType(timelineType)) {
|
||||
return timelineType;
|
||||
}
|
||||
|
||||
switch (String(event.kind)) {
|
||||
case 'task_planned':
|
||||
case 'run_started':
|
||||
return 'plan';
|
||||
case 'skill_selected':
|
||||
return 'skill';
|
||||
case 'tool_call_started':
|
||||
return 'tool_call';
|
||||
case 'tool_call_finished':
|
||||
return 'tool_result';
|
||||
case 'agent_team_created':
|
||||
return 'agent_team';
|
||||
case 'agent_handoff':
|
||||
return 'agent_handoff';
|
||||
case 'agent_finished':
|
||||
case 'run_progress':
|
||||
case 'run_finished':
|
||||
return 'agent_progress';
|
||||
case 'task_result_ready':
|
||||
return 'result';
|
||||
case 'task_acceptance_recorded':
|
||||
return 'acceptance';
|
||||
case 'task_error':
|
||||
return 'error';
|
||||
default:
|
||||
if (event.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return '任务已创建';
|
||||
case 'plan':
|
||||
return '执行计划';
|
||||
case 'skill':
|
||||
return '选择 Skill';
|
||||
case 'tool_call':
|
||||
return actorName ? `调用工具:${actorName}` : '调用工具';
|
||||
case 'tool_result':
|
||||
return actorName ? `工具结果:${actorName}` : '工具结果';
|
||||
case 'next_step':
|
||||
return '下一步';
|
||||
case 'agent_team':
|
||||
return '启动 Agent Team';
|
||||
case 'agent_progress':
|
||||
return actorName || 'Agent 进展';
|
||||
case 'agent_handoff':
|
||||
return 'Agent 交接';
|
||||
case 'artifact':
|
||||
return '生成产物';
|
||||
case 'error':
|
||||
return '执行遇到问题';
|
||||
case 'result':
|
||||
return '本轮结果';
|
||||
case 'result_history':
|
||||
return '历史结果版本';
|
||||
case 'acceptance':
|
||||
return '任务验收';
|
||||
}
|
||||
}
|
||||
|
||||
function summaryForEvent(event: ProcessEvent): string | undefined {
|
||||
return firstString(
|
||||
event.metadata?.result_summary,
|
||||
event.metadata?.reason,
|
||||
event.metadata?.action_summary,
|
||||
event.text,
|
||||
);
|
||||
}
|
||||
|
||||
function detailsForEvent(event: ProcessEvent): Record<string, unknown> | undefined {
|
||||
const skillNames = normalizeSkillNames(event.metadata);
|
||||
if (!event.metadata && !skillNames) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(event.metadata ?? {}),
|
||||
...(skillNames ? { skill_names: skillNames } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackCreatedAt(feedback: Record<string, unknown>, task: BackendTask): string {
|
||||
return firstString(feedback.created_at, task.updated_at, task.created_at) ?? task.created_at;
|
||||
}
|
||||
|
||||
function feedbackSummary(feedback: Record<string, unknown>): string | undefined {
|
||||
return firstString(feedback.comment, feedback.summary, feedback.acceptance_type);
|
||||
}
|
||||
|
||||
function acceptanceTypeFromRecord(record: Record<string, unknown> | undefined): string | null {
|
||||
return firstString(record?.acceptance_type, record?.feedback_type)?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function resultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(
|
||||
task.metadata?.result_summary,
|
||||
task.metadata?.summary,
|
||||
task.close_reason,
|
||||
task.validation_result?.summary,
|
||||
);
|
||||
}
|
||||
|
||||
function assistantResultForRun(task: BackendTask, runId: string | null | undefined): string | undefined {
|
||||
if (!runId) return undefined;
|
||||
const run = (task.runs ?? []).find((item) => item.run_id === runId);
|
||||
if (!run) return undefined;
|
||||
const assistantMessages = run.messages.filter((message) => message.role === 'assistant' && message.content.trim());
|
||||
return lastItem(assistantMessages)?.content.trim();
|
||||
}
|
||||
|
||||
function resultSummaryForEvent(task: BackendTask, event: ProcessEvent): string | undefined {
|
||||
return firstString(assistantResultForRun(task, event.run_id), summaryForEvent(event));
|
||||
}
|
||||
|
||||
function fallbackResultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(assistantResultForRun(task, lastItem(task.run_ids)), resultSummary(task));
|
||||
}
|
||||
|
||||
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
|
||||
const map = new Map<string, ProcessRun>();
|
||||
for (const run of processRuns) {
|
||||
map.set(run.run_id, run);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function lastItem<T>(items: T[]): T | null {
|
||||
return items.length > 0 ? items[items.length - 1] : null;
|
||||
}
|
||||
|
||||
function compareCardsByCreatedAt(
|
||||
a: { card: TaskTimelineCard; index: number },
|
||||
b: { card: TaskTimelineCard; index: number },
|
||||
): number {
|
||||
const aTime = toTime(a.card.createdAt);
|
||||
const bTime = toTime(b.card.createdAt);
|
||||
|
||||
if (aTime === null && bTime === null) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
if (aTime === null) {
|
||||
return 1;
|
||||
}
|
||||
if (bTime === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aTime - bTime || a.index - b.index;
|
||||
}
|
||||
|
||||
type AcceptanceEventIdentity = {
|
||||
runId: string | null;
|
||||
acceptanceType: string | null;
|
||||
};
|
||||
|
||||
function isCoveredByAcceptanceEvent(
|
||||
feedback: Record<string, unknown>,
|
||||
acceptanceEvents: AcceptanceEventIdentity[],
|
||||
): boolean {
|
||||
const feedbackType = acceptanceTypeFromRecord(feedback);
|
||||
if (!feedbackType) return false;
|
||||
|
||||
const feedbackRunId = firstString(feedback.run_id) ?? null;
|
||||
const matchingTypeEvents = acceptanceEvents.filter((event) => event.acceptanceType === feedbackType);
|
||||
|
||||
if (feedbackRunId) {
|
||||
return (
|
||||
matchingTypeEvents.some((event) => event.runId === feedbackRunId) ||
|
||||
(matchingTypeEvents.length === 1 && !matchingTypeEvents[0].runId)
|
||||
);
|
||||
}
|
||||
|
||||
return matchingTypeEvents.length === 1;
|
||||
}
|
||||
|
||||
function cardTime(card: TaskTimelineCard): number {
|
||||
return toTime(card.createdAt) ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function cardComment(card: TaskTimelineCard): string | undefined {
|
||||
return firstString(card.details?.comment, card.summary);
|
||||
}
|
||||
|
||||
function toolCallKeyFromEvent(event: ProcessEvent): string | null {
|
||||
const toolCallId = firstString(event.metadata?.tool_call_id);
|
||||
if (toolCallId) return `${event.run_id}:${toolCallId}`;
|
||||
|
||||
const toolName = firstString(event.metadata?.tool_name, event.actor_name, event.actor_id);
|
||||
if (toolName) return `${event.run_id}:${toolName}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map<string, string> {
|
||||
const statuses = new Map<string, string>();
|
||||
for (const event of processEvents) {
|
||||
if (cardTypeForEvent(event) !== 'tool_result') continue;
|
||||
const key = toolCallKeyFromEvent(event);
|
||||
if (!key) continue;
|
||||
statuses.set(key, event.status || 'done');
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[]): TaskTimelineCard {
|
||||
const versions = resultCards.map((resultCard) => {
|
||||
const acceptanceCard = acceptanceCards
|
||||
.filter((card) => card.runId === resultCard.runId)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b))
|
||||
.at(-1);
|
||||
return {
|
||||
runId: resultCard.runId ?? null,
|
||||
result: resultCard.summary ?? '',
|
||||
createdAt: resultCard.createdAt,
|
||||
status: acceptanceCard?.status ?? resultCard.status ?? null,
|
||||
acceptanceType: acceptanceCard?.status ?? null,
|
||||
comment: acceptanceCard ? cardComment(acceptanceCard) ?? '' : '',
|
||||
acceptedAt: acceptanceCard?.createdAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${task.task_id}:result-history`,
|
||||
taskId: task.task_id,
|
||||
type: 'result_history',
|
||||
title: titleForCard('result_history'),
|
||||
summary: `${resultCards.length} 历史结果版本`,
|
||||
createdAt: resultCards[0]?.createdAt ?? task.created_at,
|
||||
details: { versions },
|
||||
};
|
||||
}
|
||||
|
||||
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[]): TaskTimelineCard[] {
|
||||
const resultCards = cards.filter((card) => card.type === 'result');
|
||||
if (resultCards.length <= 1) return cards;
|
||||
|
||||
const finalAcceptedRunId = firstString(task.metadata?.final_accepted_run_id);
|
||||
const latestResult =
|
||||
(finalAcceptedRunId ? resultCards.find((card) => card.runId === finalAcceptedRunId) : undefined) ??
|
||||
[...resultCards].sort((a, b) => cardTime(a) - cardTime(b)).at(-1);
|
||||
if (!latestResult) return cards;
|
||||
|
||||
const oldResults = resultCards
|
||||
.filter((card) => card.id !== latestResult.id)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
if (oldResults.length === 0) return cards;
|
||||
|
||||
const oldRunIds = new Set(oldResults.map((card) => card.runId).filter(Boolean));
|
||||
const oldAcceptances = cards
|
||||
.filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId))
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id));
|
||||
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances);
|
||||
const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id);
|
||||
const output: TaskTimelineCard[] = [];
|
||||
|
||||
for (let index = 0; index < cards.length; index += 1) {
|
||||
if (index === firstOldResultIndex) {
|
||||
output.push(historyCard);
|
||||
}
|
||||
if (!foldedIds.has(cards[index].id)) {
|
||||
output.push(cards[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
|
||||
const { task } = input;
|
||||
const processRuns = input.processRuns ?? task.process_runs ?? [];
|
||||
const processEvents = input.processEvents ?? task.process_events ?? [];
|
||||
const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? [];
|
||||
const runsById = buildRunMap(processRuns);
|
||||
const toolResultStatusByCall = buildToolResultStatusByCall(processEvents);
|
||||
const runsWithProgressEvents = new Set<string>();
|
||||
const acceptanceEvents: AcceptanceEventIdentity[] = [];
|
||||
let hasResultEventCard = false;
|
||||
const cards: TaskTimelineCard[] = [
|
||||
{
|
||||
id: `${task.task_id}:created`,
|
||||
taskId: task.task_id,
|
||||
type: 'task_created',
|
||||
title: titleForCard('task_created'),
|
||||
summary: firstString(task.short_title, task.description, task.goal),
|
||||
actorName: task.creator,
|
||||
status: task.status,
|
||||
createdAt: task.created_at,
|
||||
details: task.metadata,
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of processEvents) {
|
||||
const type = cardTypeForEvent(event);
|
||||
if (!type) continue;
|
||||
if (type === 'agent_progress') {
|
||||
runsWithProgressEvents.add(event.run_id);
|
||||
}
|
||||
if (type === 'result') {
|
||||
hasResultEventCard = true;
|
||||
}
|
||||
if (type === 'acceptance') {
|
||||
acceptanceEvents.push({
|
||||
runId: firstString(event.run_id) ?? null,
|
||||
acceptanceType: acceptanceTypeFromRecord(event.metadata),
|
||||
});
|
||||
}
|
||||
|
||||
cards.push({
|
||||
id: event.event_id,
|
||||
taskId: task.task_id,
|
||||
runId: event.run_id,
|
||||
parentRunId: event.parent_run_id,
|
||||
type,
|
||||
title: titleForCard(type, event.actor_name),
|
||||
summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event),
|
||||
actorName: event.actor_name,
|
||||
status:
|
||||
type === 'tool_call'
|
||||
? toolResultStatusByCall.get(toolCallKeyFromEvent(event) ?? '') ?? event.status
|
||||
: event.status,
|
||||
createdAt: event.created_at,
|
||||
details: detailsForEvent(event),
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
if (runsWithProgressEvents.has(run.run_id)) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${run.run_id}:fallback-progress`,
|
||||
taskId: task.task_id,
|
||||
runId: run.run_id,
|
||||
parentRunId: run.parent_run_id,
|
||||
type: 'agent_progress',
|
||||
title: titleForCard('agent_progress', run.actor_name),
|
||||
summary: firstString(run.summary, run.title),
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
createdAt: run.started_at,
|
||||
details: run.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (const artifact of processArtifacts) {
|
||||
const run = runsById.get(artifact.run_id);
|
||||
cards.push({
|
||||
id: artifact.artifact_id,
|
||||
taskId: task.task_id,
|
||||
runId: artifact.run_id,
|
||||
parentRunId: run?.parent_run_id,
|
||||
type: 'artifact',
|
||||
title: titleForCard('artifact'),
|
||||
summary: firstString(artifact.title),
|
||||
actorName: artifact.actor_name,
|
||||
createdAt: artifact.created_at,
|
||||
relatedArtifactIds: [artifact.artifact_id],
|
||||
details: {
|
||||
...(artifact.metadata ?? {}),
|
||||
artifact_type: artifact.artifact_type,
|
||||
title: artifact.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (RESULT_STATUSES.has(task.status) && !hasResultEventCard) {
|
||||
cards.push({
|
||||
id: `${task.task_id}:result`,
|
||||
taskId: task.task_id,
|
||||
runId: lastItem(task.run_ids),
|
||||
type: 'result',
|
||||
title: titleForCard('result'),
|
||||
summary: fallbackResultSummary(task),
|
||||
status: task.status,
|
||||
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
|
||||
details: task.validation_result ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < task.feedback.length; index += 1) {
|
||||
const feedback = task.feedback[index];
|
||||
const runId = firstString(feedback.run_id) ?? null;
|
||||
const createdAt = feedbackCreatedAt(feedback, task);
|
||||
if (isCoveredByAcceptanceEvent(feedback, acceptanceEvents)) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${task.task_id}:acceptance:${index}`,
|
||||
taskId: task.task_id,
|
||||
runId,
|
||||
type: 'acceptance',
|
||||
title: titleForCard('acceptance'),
|
||||
summary: feedbackSummary(feedback),
|
||||
status: firstString(feedback.acceptance_type),
|
||||
createdAt,
|
||||
details: feedback,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedCards = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.sort(compareCardsByCreatedAt)
|
||||
.map(({ card }) => card);
|
||||
|
||||
return collapseHistoricalResults(task, sortedCards);
|
||||
}
|
||||
32
app-instance/frontend/lib/user-files-api.test.ts
Normal file
32
app-instance/frontend/lib/user-files-api.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
describe('user file system frontend wiring', () => {
|
||||
it('routes API client helpers to user file endpoints', () => {
|
||||
const apiSource = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
|
||||
|
||||
expect(apiSource).toContain('/api/user-files/browse');
|
||||
expect(apiSource).toContain('/api/user-files/upload');
|
||||
expect(apiSource).toContain('/api/user-files/download');
|
||||
expect(apiSource).toContain('/api/user-files/preview');
|
||||
expect(apiSource).toContain('/api/user-files/delete');
|
||||
expect(apiSource).toContain('/api/user-files/mkdir');
|
||||
});
|
||||
|
||||
it('does not wire the Files page to workspace or MinIO management APIs', () => {
|
||||
const pageSource = readFileSync(resolve(root, 'app/(app)/files/page.tsx'), 'utf8');
|
||||
|
||||
expect(pageSource).toContain('browseUserFiles');
|
||||
expect(pageSource).toContain('uploadUserFile');
|
||||
expect(pageSource).not.toContain('browseWorkspace');
|
||||
expect(pageSource).not.toContain('uploadToWorkspace');
|
||||
expect(pageSource).not.toContain('MinIO');
|
||||
expect(pageSource).not.toContain('bucket');
|
||||
expect(pageSource).not.toContain('accessKey');
|
||||
expect(pageSource).not.toContain('secretKey');
|
||||
});
|
||||
});
|
||||
@ -48,9 +48,8 @@ export interface ChatMessage {
|
||||
run_id?: string;
|
||||
task_id?: string | null;
|
||||
task_status?: string | null;
|
||||
evidence_status?: 'recorded';
|
||||
acceptance_state?: 'accept' | 'revise' | 'abandon';
|
||||
feedback_state?: 'accept' | 'satisfied' | 'revise' | 'abandon';
|
||||
validation_status?: 'passed' | 'failed' | 'unknown';
|
||||
feedback_state?: 'satisfied' | 'revise' | 'abandon';
|
||||
feedback_error?: string;
|
||||
message_type?: string | null;
|
||||
scheduled_job_id?: string | null;
|
||||
@ -142,12 +141,6 @@ export interface ProviderConfigPayload {
|
||||
request_timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface AgentConfigPayload {
|
||||
max_tokens: number | null;
|
||||
temperature: number;
|
||||
max_tool_iterations: number;
|
||||
}
|
||||
|
||||
export interface ChannelStatus {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
@ -159,8 +152,7 @@ export interface SystemStatus {
|
||||
workspace: string;
|
||||
workspace_exists: boolean;
|
||||
model: string;
|
||||
max_tokens: number | null;
|
||||
max_context_messages?: number;
|
||||
max_tokens: number;
|
||||
temperature: number;
|
||||
max_tool_iterations: number;
|
||||
providers: ProviderStatus[];
|
||||
@ -323,7 +315,6 @@ export interface BackendTaskRun {
|
||||
attempt_index?: number | null;
|
||||
task_text?: string;
|
||||
messages: BackendTaskRunMessage[];
|
||||
evidence_status?: string | null;
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@ -440,7 +431,7 @@ export interface SkillHubInstallResponse {
|
||||
already_installed?: boolean;
|
||||
}
|
||||
|
||||
export type ProcessActorType = 'agent' | 'mcp' | 'system' | 'user';
|
||||
export type ProcessActorType = 'agent' | 'mcp' | 'system';
|
||||
export type ProcessRunStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
@ -455,17 +446,7 @@ export type ProcessEventKind =
|
||||
| 'run_artifact'
|
||||
| 'run_status'
|
||||
| 'run_finished'
|
||||
| 'run_cancelled'
|
||||
| 'task_planned'
|
||||
| 'skill_selected'
|
||||
| 'tool_call_started'
|
||||
| 'tool_call_finished'
|
||||
| 'agent_team_created'
|
||||
| 'agent_finished'
|
||||
| 'agent_handoff'
|
||||
| 'task_result_ready'
|
||||
| 'task_acceptance_recorded'
|
||||
| 'task_error';
|
||||
| 'run_cancelled';
|
||||
|
||||
export interface UiAgentDescriptor {
|
||||
id: string;
|
||||
@ -787,37 +768,6 @@ export interface ProcessArtifact {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type TaskTimelineCardType =
|
||||
| 'task_created'
|
||||
| 'plan'
|
||||
| 'skill'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'next_step'
|
||||
| 'agent_team'
|
||||
| 'agent_progress'
|
||||
| 'agent_handoff'
|
||||
| 'artifact'
|
||||
| 'error'
|
||||
| 'result'
|
||||
| 'result_history'
|
||||
| 'acceptance';
|
||||
|
||||
export interface TaskTimelineCard {
|
||||
id: string;
|
||||
taskId: string;
|
||||
runId?: string | null;
|
||||
parentRunId?: string | null;
|
||||
type: TaskTimelineCardType;
|
||||
title: string;
|
||||
summary?: string;
|
||||
actorName?: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
relatedArtifactIds?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SessionProcessProjection {
|
||||
runs: ProcessRun[];
|
||||
events: ProcessEvent[];
|
||||
@ -1022,12 +972,12 @@ export interface ChatAssistantEvent {
|
||||
run_id?: string;
|
||||
task_id?: string | null;
|
||||
task_status?: string | null;
|
||||
evidence_status?: 'recorded';
|
||||
validation_status?: 'passed' | 'failed' | 'unknown';
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
metadata?: {
|
||||
task_id?: string | null;
|
||||
task_status?: string | null;
|
||||
evidence_status?: string | null;
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@ -46,6 +46,21 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return normalized
|
||||
|
||||
|
||||
def read_registry(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return _default_data()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return _default_data()
|
||||
if not isinstance(data, dict):
|
||||
return _default_data()
|
||||
if not isinstance(data.get("instances"), list):
|
||||
data["instances"] = []
|
||||
data["instances"] = [_normalize_record(item) for item in data["instances"] if isinstance(item, dict)]
|
||||
return data
|
||||
|
||||
|
||||
@contextmanager
|
||||
def locked_registry(path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@ -118,8 +133,8 @@ def _get_record(
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
with locked_registry(path) as data:
|
||||
instances = list(data["instances"])
|
||||
data = read_registry(path)
|
||||
instances = list(data["instances"])
|
||||
if args.json:
|
||||
json.dump({"instances": instances}, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
@ -143,15 +158,15 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_get(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
with locked_registry(path) as data:
|
||||
record = _get_record(
|
||||
data,
|
||||
instance_id=args.instance_id,
|
||||
slug=args.slug,
|
||||
container_name=args.container_name,
|
||||
username=args.username,
|
||||
instance_host=args.instance_host,
|
||||
)
|
||||
data = read_registry(path)
|
||||
record = _get_record(
|
||||
data,
|
||||
instance_id=args.instance_id,
|
||||
slug=args.slug,
|
||||
container_name=args.container_name,
|
||||
username=args.username,
|
||||
instance_host=args.instance_host,
|
||||
)
|
||||
if record is None:
|
||||
return 1
|
||||
json.dump(record, sys.stdout, indent=2, ensure_ascii=False)
|
||||
|
||||
@ -11,7 +11,7 @@ http {
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 50m;
|
||||
client_max_body_size 5g;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
@ -69,4 +69,3 @@ http {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user