diff --git a/.gitignore b/.gitignore index e57b211..a1fd699 100644 --- a/.gitignore +++ b/.gitignore @@ -329,3 +329,6 @@ deploy/static/releases/*.ipa # Local agents and skills .agents/ + +# Midscene automation reports and screenshots +midscene_run/ diff --git a/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/check.jsonl b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/check.jsonl new file mode 100644 index 0000000..f865dec --- /dev/null +++ b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/check.jsonl @@ -0,0 +1,15 @@ +{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Completion checklist for cross-layer work"} +{"file": ".opencode/commands/trellis/check-backend.md", "reason": "Backend verification checklist"} +{"file": ".opencode/commands/trellis/check-frontend.md", "reason": "Frontend verification checklist"} +{"file": "AGENTS.md", "reason": "Project-level constraints and protocol source of truth"} +{"file": "backend/AGENTS.md", "reason": "Backend runtime/tool contract rules"} +{"file": "apps/AGENTS.md", "reason": "Frontend AG-UI and renderer constraints"} +{"file": "backend/src/core/agentscope/tools/toolkit.py", "reason": "Verify tool registration moved off direct Python bindings as intended"} +{"file": "backend/src/schemas/agent/runtime_models.py", "reason": "Verify worker output no longer requires ui_hints"} +{"file": "backend/src/core/agentscope/runtime/stage_emitter.py", "reason": "Verify worker event payload shape after ui_hints removal"} +{"file": "backend/src/v1/agent/utils.py", "reason": "Verify history payload no longer depends on runtime ui_hints compilation"} +{"file": "apps/lib/core/chat/ag_ui_event.dart", "reason": "Verify frontend event/history parsing matches new payload contract"} +{"file": "apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart", "reason": "Verify renderer consumes tool-schema derived UI contract"} +{"file": "docs/protocols/ui/ui-schema.md", "reason": "Verify protocol docs match implementation after refactor"} +{"file": "docs/protocols/ui/data-flow.md", "reason": "Verify documented data flow no longer depends on worker ui_hints"} +{"file": "docs/protocols/agent/sse-events.md", "reason": "Verify SSE contract documents updated worker/tool payloads"} diff --git a/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/implement.jsonl b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/implement.jsonl new file mode 100644 index 0000000..1befed1 --- /dev/null +++ b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/implement.jsonl @@ -0,0 +1,20 @@ +{"file": ".trellis/workflow.md", "reason": "Trellis workflow and task execution rules"} +{"file": ".trellis/spec/backend/index.md", "reason": "Backend development constraints and verification baseline"} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend rendering and app-layer conventions"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Cross-layer contract checklist for protocol/backend/frontend changes"} +{"file": "AGENTS.md", "reason": "Project routing, protocol-source-of-truth, and git safety constraints"} +{"file": "backend/AGENTS.md", "reason": "Backend schema/tooling/runtime rules"} +{"file": "apps/AGENTS.md", "reason": "Frontend AG-UI and shared rendering rules"} +{"file": "backend/src/core/agentscope/tools/tool_config.py", "reason": "Current tool registry and tool name mapping"} +{"file": "backend/src/core/agentscope/tools/toolkit.py", "reason": "Current direct Python tool registration path"} +{"file": "backend/src/v1/agent/system_agents_config.py", "reason": "Worker enabled_tools loading from system agent config"} +{"file": "backend/src/schemas/agent/runtime_models.py", "reason": "Current worker output contract includes ui_hints"} +{"file": "backend/src/core/agentscope/runtime/stage_emitter.py", "reason": "Current TEXT_MESSAGE_END payload still emits ui_hints"} +{"file": "backend/src/v1/agent/utils.py", "reason": "History path still compiles ui_hints into ui_schema"} +{"file": "apps/lib/core/chat/ag_ui_event.dart", "reason": "Frontend event/history parser consumes ui_schema from backend"} +{"file": "apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart", "reason": "Current UI renderer consumes compiled ui_schema"} +{"file": "docs/protocols/ui/ui-schema.md", "reason": "UI schema protocol source of truth"} +{"file": "docs/protocols/ui/data-flow.md", "reason": "Current worker ui_hints -> ui_schema data-flow documentation"} +{"file": "docs/protocols/agent/sse-events.md", "reason": "SSE payload contract for worker and tool events"} +{"file": "docs/protocols/agent/run-agent-input.md", "reason": "Run-agent contract and ui_schema references"} +{"file": "docs/protocols/agent/api-endpoints.md", "reason": "HTTP endpoint contract for history and events payloads"} diff --git a/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/implementation-checklist.md b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/implementation-checklist.md new file mode 100644 index 0000000..9d3a848 --- /dev/null +++ b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/implementation-checklist.md @@ -0,0 +1,410 @@ +# AgentScope Skill + CLI Tool Refactor Implementation Checklist + +## Purpose + +This file is the execution checklist for implementing the PRD in: + +- `.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/prd.md` + +Use this document as the working guide during implementation. +Do not mark an item complete until the code, docs, and verification for that item are actually done. + +## Required Standards Read Before Backend Changes + +- [x] Read `backend/AGENTS.md` +- [x] Read `.trellis/spec/backend/index.md` +- [x] Read `.trellis/spec/backend/database-guidelines.md` +- [x] Read `.trellis/spec/backend/error-handling.md` +- [x] Read `.trellis/spec/backend/logging-guidelines.md` +- [x] Read `.trellis/spec/backend/quality-guidelines.md` +- [x] Confirm `.trellis/spec/backend/type-safety.md` does not exist; use current backend schema/type rules from `backend/AGENTS.md` and repository code as the effective type-safety baseline + +## Non-Negotiable Constraints + +- [x] Protocol docs are updated before implementation changes that alter contracts +- [ ] New backend runtime code reads configuration only through `core.config.settings` +- [ ] New backend runtime code uses project logging, never `print()` +- [ ] New backend errors follow RFC 7807 with stable `code` +- [ ] Any new or changed error codes are updated in `docs/protocols/common/http-error-codes.md` +- [ ] Repository/service layering remains intact +- [ ] `owner_id` is never treated as a credential +- [ ] No new error swallowing is introduced +- [x] `ToolAgentOutput.result` remains the canonical machine-oriented tool result field + +## Execution Order + +- [ ] Phase 0 completed before any runtime contract change is implemented +- [x] Phase 1 completed before replacing tool execution with CLI-backed wrappers +- [ ] Phase 2 completed before auth credential transport is wired into queue/runtime +- [ ] Phase 3 completed before frontend contract alignment begins +- [ ] Phase 4 completed before cleanup is considered done +- [ ] Phase 5 verification completed before task is marked finished + +## Phase 0: Protocol Docs First + +### 0.1 Define the tool protocol source of truth + +- [x] Add `docs/protocols/agent/tool-protocol.md` +- [x] Document that CLI execution produces structured `result` as the source payload +- [x] Document that `ToolResponse` only carries the text projection of `result` +- [x] Document that runtime tool post-processing reconstructs full `ToolAgentOutput` +- [x] Document that tool post-processing is responsible for `status`, `error`, and `ui_hints` +- [x] Document that `message.content` is the full JSON text projection of `ToolAgentOutput.result` +- [x] Document that `ToolAgentOutput` is used for SSE, persistence, history recovery, and context rebuild +- [x] Document CLI input channel split: `argv` primary, `stdin` secondary, environment variables for controlled auth injection +- [x] Document stdout JSON shape and non-zero exit semantics +- [x] Document that shell execution is not exposed; router is whitelist-only + +### 0.2 Remove `ui_schema` from active protocol + +- [x] Update `docs/protocols/ui/data-flow.md` +- [x] Replace worker-driven UI source descriptions with tool-driven `ui_hints` +- [x] Explicitly document that worker output no longer includes `ui_hints` +- [x] Explicitly document that history tool UI recovery reads `metadata.tool_agent_output.ui_hints` and compiles to `ui_schema` +- [x] Update `docs/protocols/ui/ui-schema.md` +- [x] Clarify that `ui_hints` is the descriptive UI representation (source), `ui_schema` is the rendered format (wire format) +- [x] Clarify that frontend renderer continues to consume `ui_schema` +- [x] Document that `ui_hints → ui_schema` compilation path remains unchanged, only `ui_hints` source changes + +### 0.3 Update SSE and HTTP contracts + +- [x] Update `docs/protocols/agent/sse-events.md` +- [x] Remove worker `ui_hints` from `TEXT_MESSAGE_END` +- [x] Define `TOOL_CALL_RESULT` payload with `ui_schema` (compiled from `ui_hints`) +- [x] Document that `ui_hints → ui_schema` compilation happens in backend codec +- [x] Provide examples where `result` is object-shaped instead of string-shaped +- [x] Update `docs/protocols/agent/api-endpoints.md` +- [x] Define `/history` response contract for tool UI replay from `metadata.tool_agent_output.ui_hints` compiled to `ui_schema` +- [x] Remove any statement that `/history` is assistant-only UI-wise if tool UI replay is now supported +- [x] Update `docs/protocols/agent/run-agent-input.md` +- [x] Clarify that frontend does not submit auth token as tool arg +- [x] Clarify that backend-controlled tool registration remains backend-owned + +### 0.4 Update auth and automation protocol docs + +- [x] Update `docs/protocols/models/auth.md` +- [x] Define controlled credential purpose, TTL, scope, and audit expectations +- [x] Define relationship between normal bearer issuer and automation credential issuer +- [x] Update `docs/protocols/models/automation-jobs.md` +- [x] State that `owner_id` is only an identity reference, not a credential +- [x] Document automation credential issuance path before queue/runtime execution +- [x] Update `docs/protocols/common/http-error-codes.md` if new codes are introduced for CLI/runtime/credential failures + +### 0.5 Phase 0 verification + +- [x] Confirm protocol docs no longer describe worker `ui_hints` as UI source +- [x] Confirm protocol docs explicitly document `ui_hints → ui_schema` compilation path +- [x] Confirm docs explicitly define ToolResponse vs ToolAgentOutput responsibility split +- [x] Confirm docs explicitly define `/history` tool UI replay path (from `ui_hints` compiled to `ui_schema`) +- [x] Confirm docs explicitly define controlled credential transport and TTL + +## Phase 1: Backend Contract Models And Persistence Path + +### 1.1 Refactor runtime schemas + +- [x] Update `backend/src/schemas/agent/runtime_models.py` +- [x] Remove `WorkerAgentOutputRich.ui_hints` +- [x] Remove `AgentOutput` inheritance that depends on worker UI payload +- [x] Make `resolve_worker_output_model()` return the non-UI worker output model path +- [x] Change `ToolAgentOutput.result` from `str` to JSON-native structured payload type +- [x] Add `ui_hints` to `ToolAgentOutput` +- [x] Keep `ToolAgentOutput` strict with `extra="forbid"` +- [x] Review any validator changes required to keep result deterministic and JSON-native + +### 1.2 Update chat message metadata schema consumers + +- [x] Review `backend/src/schemas/domain/chat_message.py` +- [x] Ensure `tool_agent_output` accepts the updated structured `ToolAgentOutput` +- [x] Confirm metadata serialization remains compatible with persistence and context cache usage + +### 1.3 Separate ToolResponse from ToolAgentOutput + +- [x] Update `backend/src/core/agentscope/tools/utils/tool_response_builder.py` +- [x] Stop serializing full `ToolAgentOutput` directly into `ToolResponse.content` +- [x] Make `build_tool_response()` emit only the text projection of `result` +- [x] Decide and implement the helper that projects structured `result` to stable JSON text +- [x] Update error response builder to follow the same split cleanly + +### 1.4 Add tool post-processing path + +- [x] Introduce a runtime tool post-processing module in backend tool/runtime layer +- [x] Define the post-processor input contract from raw tool execution result +- [x] Define the post-processor output as full `ToolAgentOutput` +- [x] Ensure post-processor is the only place generating `ui_hints` for tools +- [x] Ensure worker code does not generate tool UI fields anymore + +### 1.5 Update parsing and stage emission + +- [x] Update `backend/src/core/agentscope/utils/parsing.py` +- [x] Stop assuming text blocks contain full serialized `ToolAgentOutput` +- [x] Add helpers to parse the text projection back into structured result where required +- [x] Update `backend/src/core/agentscope/runtime/stage_emitter.py` +- [x] Remove worker `ui_hints` emission from final text events +- [x] Emit `TOOL_CALL_RESULT` based on full post-processed `ToolAgentOutput` +- [x] Ensure emitted tool payload carries structured `result` and `ui_hints` + +### 1.6 Update AG-UI codec and event storage + +- [x] Update `backend/src/core/agentscope/events/agui_codec.py` +- [x] Remove worker `ui_hints -> ui_schema` compilation path +- [x] Remove `ui_schema`-specific output shaping +- [x] Ensure tool events pass through tool-derived `ui_hints` +- [x] Update `backend/src/core/agentscope/events/store.py` +- [x] Persist tool message `content` as the JSON text projection of `result` +- [x] Persist full post-processed `ToolAgentOutput` in metadata +- [x] Ensure worker metadata no longer expects `ui_hints` + +### 1.7 Unify cold/hot runtime paths + +- [x] Update `backend/src/core/agentscope/runtime/tasks.py` +- [x] Replace `_serialize_tool_agent_output()` assumptions that rely on old `ToolAgentOutput` shape +- [x] Ensure context rebuild uses the same content projection rule as hot-path execution +- [x] Stop rebuilding tool context from legacy string-only result assumptions +- [x] Review `backend/src/core/agentscope/caches/context_messages_cache.py` +- [x] Define whether old cache payloads are backward-read compatible or intentionally invalidated +- [x] Ensure runtime cold path and hot path see the same tool message shape + +### 1.8 Update `/history` backend shaping + +- [x] Update `backend/src/v1/agent/utils.py` +- [x] Remove worker `ui_hints` compilation logic +- [x] Stop returning `ui_schema` +- [x] Add tool UI replay logic from `metadata.tool_agent_output.ui_hints` +- [x] Keep user attachment handling intact +- [x] Update `backend/src/v1/agent/schemas.py` +- [x] Remove `UiSchemaRenderer` dependency from `HistoryMessage` +- [x] Redefine history response shape to carry tool UI replay payload +- [x] Update role constraints if tool-derived history items need explicit representation +- [x] Review `backend/src/v1/agent/repository.py` for any history query assumptions that prevent tool UI replay + +### 1.9 Phase 1 verification + +- [x] Unit tests cover `ToolAgentOutput.result` as structured payload +- [x] Unit tests confirm worker output schema no longer includes `ui_hints` +- [x] Unit tests confirm ToolResponse no longer embeds full ToolAgentOutput +- [x] Unit tests confirm event store persists full ToolAgentOutput metadata and projected content separately +- [x] Unit tests confirm `/history` shaping no longer emits `ui_schema` +- [x] Unit tests confirm tool UI replay uses `metadata.tool_agent_output.ui_hints` + +## Phase 2: CLI-Backed Tools And Skill Registration + +### 2.1 Replace direct Python tool registration + +- [x] Update `backend/src/core/agentscope/tools/tool_config.py` +- [x] Replace function-name-centric mapping with CLI capability/wrapper-centric mapping +- [x] Unify config and runtime skill selection on `enabled_skills` +- [x] Keep approval config support aligned with the new tool names +- [x] Update `backend/src/core/agentscope/tools/toolkit.py` +- [x] Remove direct imports of `custom/calendar.py`, `custom/memory.py`, `custom/user_lookup.py` +- [x] Register CLI-backed wrappers instead of Python business functions +- [x] Preserve `enabled_skills` filtering behavior + +### 2.2 Add CLI adapter, router, and entrypoint + +- [x] Add a CLI adapter module in `backend/src/core/agentscope/tools/` +- [x] Adapter must invoke only the project CLI entrypoint +- [x] Adapter must pass args via `argv` primarily and `stdin` secondarily where required +- [x] Adapter must inject auth credential only via controlled environment variables +- [x] Adapter must parse stdout JSON and map failures to structured errors +- [x] Add a command router module in `backend/src/core/agentscope/tools/` +- [x] Router must be whitelist-only +- [x] Router must map commands to Python handlers +- [x] Router must not expose generic shell execution +- [x] Add a Python console entrypoint module in `backend/src/core/agentscope/tools/` +- [x] Update `pyproject.toml` with the console script entry + +### 2.3 Migrate tool implementations to CLI handlers + +- [x] Replace old `backend/src/core/agentscope/tools/custom/*.py` direct runtime tools with CLI handler implementations +- [x] Remove old direct AgentScope tool-function implementations from final runtime wiring +- [x] Ensure new handlers only call allowed internal services/repositories +- [x] Ensure handler boundaries follow schema -> repository -> service layering +- [x] Ensure handlers raise typed errors instead of transport exceptions where applicable + +### 2.4 Register AgentScope skills + +- [x] Populate `backend/src/core/agentscope/tools/custom` with skill assets using AgentScope-native layout +- [x] Add required `SKILL.md` files +- [x] Ensure skill content explains when to use each tool and how to compose them +- [x] Register skills through AgentScope-native registration path in toolkit/runtime setup +- [x] Ensure skill assets are included in runtime/deployment packaging + +### 2.5 Update runner and middleware linkages + +- [x] Update `backend/src/core/agentscope/runtime/runner.py` +- [x] Build toolkit from CLI-backed wrappers instead of Python functions +- [x] Keep `enabled_skills` and stage-based selection behavior intact +- [x] Update `backend/src/core/agentscope/tools/tool_middleware.py` +- [x] Ensure middleware name resolution still works with the new tool registration path +- [x] Update `backend/src/core/agentscope/prompts/agent_prompt.py` +- [x] Remove any prompt assumptions that still act as pseudo-skill behavior +- [x] Keep prompt aligned with skill-driven disclosure instead of duplicating the full tool contract + +### 2.6 Phase 2 verification + +- [x] Unit tests cover CLI adapter success path +- [x] Unit tests cover CLI adapter malformed stdout path +- [x] Unit tests cover CLI adapter non-zero exit path +- [x] Unit tests confirm toolkit only registers enabled CLI-backed tools +- [x] Unit tests confirm middleware still recognizes the active tool names +- [x] Smoke test confirms AgentScope skill registration succeeds from project skill assets + +## Phase 3: Controlled Credential And Queue Transport + +### 3.1 Define backend auth runtime objects + +- [x] Review `backend/src/core/auth/models.py` +- [x] Add any missing auth runtime model needed for controlled credential transport +- [x] Keep `CurrentUser` as identity model if still appropriate, but do not overload it as credential carrier without an explicit design + +### 3.2 Add controlled credential issuance path + +- [x] Add a credential issuer service under `backend/src/core/auth/` or another appropriate auth module +- [x] Keep issuer in the same trust boundary as current bearer token issuing system +- [x] Ensure issued credential is short-lived according to PRD target +- [x] Ensure issuer encodes only the minimal scope required for tool execution +- [x] Ensure logs do not expose raw credentials + +### 3.3 Wire chat enqueue path + +- [x] Update `backend/src/v1/agent/service.py` +- [x] Stop enqueueing only `owner_id` for runtime auth purposes +- [x] Enqueue the controlled credential or resolvable credential handle required by worker runtime +- [x] Ensure queue payload does not expose raw token in model-visible fields +- [x] Keep session ownership checks intact + +### 3.4 Wire automation dispatch path + +- [x] Update `backend/src/core/automation/scheduler.py` +- [x] Stop creating runtime auth solely as `CurrentUser(id=owner_id)` +- [x] Issue or obtain automation controlled credential before enqueueing run +- [x] Ensure `owner_id` remains only a lookup/reference input +- [x] Ensure automation runtime uses the same CLI auth injection mechanism as chat runtime + +### 3.5 Update task runtime injection + +- [x] Update `backend/src/core/agentscope/runtime/tasks.py` +- [x] Read controlled credential from queued command payload +- [x] Inject controlled credential into CLI runtime environment variables +- [x] Remove any path that implicitly depends on `owner_id` as execution credential +- [x] Keep user-context loading behavior explicit and separate from auth credential handling + +### 3.6 Add settings and error mapping + +- [x] Update `backend/src/core/config/settings.py` for any new CLI/credential configuration +- [x] Keep new config values typed and centralized +- [x] Update error handling paths to use stable problem codes for credential/CLI failures +- [x] Update docs/protocols/common/http-error-codes.md if these codes are new + +### 3.7 Phase 3 verification + +- [x] Unit tests confirm chat enqueue includes required controlled credential transport data +- [x] Unit tests confirm automation dispatch no longer relies on `owner_id` as credential +- [x] Unit tests confirm task runtime injects controlled credential only via env vars +- [x] Unit tests confirm credential issuance TTL and scope constraints +- [x] Logs and error payloads do not expose raw credentials + +## Phase 4: Frontend Contract Alignment + +### 4.1 Update event parsing + +- [x] Update `apps/lib/core/chat/ag_ui_event.dart` +- [x] Remove active wire parsing paths that depend on `ui_schema` +- [x] Parse tool event `ui_hints` directly from updated payload contract +- [x] Parse structured `result` instead of string-only assumptions + +### 4.2 Update history parsing and cache + +- [x] Update `apps/lib/core/chat/chat_history_repository.dart` +- [x] Align cached history format with the new backend history response shape +- [x] Ensure history replay can rebuild tool UI items from backend-provided tool metadata/UI payload + +### 4.3 Update chat service and item models + +- [x] Update `apps/lib/core/chat/ag_ui_service.dart` +- [x] Ensure SSE handling matches the new tool event contract +- [x] Update `apps/lib/core/chat/chat_list_item.dart` +- [x] Remove item model assumptions that a rendered UI payload must be named `uiSchema` + +### 4.4 Update rendering path + +- [x] Update `apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart` +- [x] Ensure tool results become visible UI items through direct tool payloads +- [x] Update `apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart` +- [x] Continue reusing the existing renderer component if it still fits the new input shape +- [x] Update `apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart` only as needed to accept the new direct tool UI input contract + +### 4.5 Phase 4 verification + +- [x] Frontend tests confirm SSE tool event parsing without `ui_schema` +- [x] Frontend tests confirm history replay rebuilds tool UI correctly +- [x] Frontend tests confirm refresh/reload still shows prior tool UI consistently + +## Phase 5: Cleanup, Regression Tests, And Final Validation + +### 5.1 Backend test updates + +- [x] Update `backend/tests/unit/core/agentscope/events/test_store.py` +- [x] Update `backend/tests/unit/core/agentscope/events/test_agui_codec.py` +- [x] Update `backend/tests/unit/core/agentscope/runtime/test_stage_emitter.py` +- [x] Update `backend/tests/unit/core/agentscope/runtime/test_tasks.py` +- [x] Update `backend/tests/unit/v1/agent/test_utils.py` +- [x] Update `backend/tests/unit/schemas/agent/test_runtime_models.py` +- [x] Add tests for CLI adapter, command router, and tool post-processing +- [x] Add tests for controlled credential issuance and queue transport + +### 5.2 Frontend test updates + +- [x] Update `apps/test/core/chat/ag_ui_event_test.dart` +- [x] Update `apps/test/features/chat/presentation/bloc/chat_bloc_test.dart` +- [x] Add tests for history repository if needed by the new replay contract + +### 5.3 Remove obsolete code paths + +- [x] Remove worker `ui_hints` usage from runtime/event/history code paths +- [x] Remove active `ui_schema` contract usage from backend response shaping (N/A - ui_schema is still used as wire format) +- [x] Remove old direct `custom/*.py` tool runtime wiring +- [x] Remove any parsing logic that assumes `ToolResponse` carries full ToolAgentOutput JSON +- [x] Remove dead compatibility helpers only after replacement path is verified + +### 5.4 Run verification commands + +- [x] Run relevant backend unit tests with `uv run pytest ...` +- [x] Run relevant frontend tests +- [x] Run backend lint checks required for touched files +- [x] Run backend type checks required for touched files +- [x] If skill registration/package wiring changed, run a focused smoke check of the CLI-backed tool path + +### 5.5 Final acceptance audit against PRD + +- [x] `ui_hints → ui_schema` compilation path is preserved (only `ui_hints` source changes from worker to tool) +- [x] `WorkerAgentOutput` no longer has `ui_hints` +- [x] `/history` tool UI replay compiles `metadata.tool_agent_output.ui_hints` to `ui_schema` +- [x] `ToolResponse` carries only projected result text +- [x] Tool post-processor generates full `ToolAgentOutput` +- [x] `ToolAgentOutput.result` is structured and machine-oriented +- [x] `message.content` is the full JSON text projection of `result` +- [x] CLI uses whitelist router and no shell execution path +- [x] Chat and automation both use controlled credential injection, not `owner_id` as credential +- [x] AgentScope skills are registered from project skill assets +- [x] Hot path and cold path tool context are unified +- [x] Frontend receives `ui_schema` from `TOOL_CALL_RESULT` and history +- [x] Relevant docs, tests, lint, and type checks are updated + +## Suggested First Implementation Slice + +- [ ] Complete Phase 0 only +- [ ] Do not start backend runtime refactor until Phase 0 contract text is committed and reviewed + +## Progress Log + +- [x] Phase 0 complete +- [x] Phase 1 complete +- [x] Phase 2 complete +- [x] Phase 3 complete +- [x] Phase 4 complete +- [x] Phase 5 complete +- [x] 2026-04-23: finished frontend cleanup for legacy tool-call interim events/cards; tool UI render path is now `TOOL_CALL_RESULT` + history replay only +- [x] 2026-04-23: documented `messages.content` decision to remain `text` (structured payload stays in metadata) +- [x] 2026-04-23: removed CLI alias compatibility and switched to canonical subcommands (`calendar.create/read/update/delete/share`, `contacts.read`, `memory.update`) +- [x] 2026-04-23: expanded protocol and postprocessor policy so canonical CRUD commands emit `ui_hints` (`calendar.create/read/update/delete`, `contacts.read`, `memory.update`) diff --git a/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/prd.md b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/prd.md new file mode 100644 index 0000000..2129c93 --- /dev/null +++ b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/prd.md @@ -0,0 +1,738 @@ +# AgentScope Skill + CLI Tool Protocol Refactor PRD + +## 1. Goal + +本次重构的目标不是发明一套新的 agent 工具体系,而是把当前项目的工具执行、skill 使用、UI 生成这三条链路收敛到一条一致的协议路径上: + +1. 以 AgentScope 原生 `tools` 和 `skills` 能力为基础。 +2. 将当前项目工具包装为符合 AgentScope 工具协议的 CLI 工具。 +3. skill 不负责执行工具,而是负责向 agent 渐进式披露"有哪些能力、什么时候用、怎么用"。 +4. CLI 工具的 runtime 认证边界以用户登录 token 为主,默认通过受控环境变量注入,而不是直接注入数据库 session / owner_id。 +5. worker agent output 不再输出 `ui_hints`。 +6. tool output 继续保留独立结构化协议对象,并可携带 `ui_hints` 给前端渲染使用。 +7. worker 在 ReAct loop 中消费的工具信息应收敛为 tool message `content`,而不是完整 `ToolAgentOutput` 元数据对象。 +8. `message.content` 继续保持文本投影,不升级为通用 schema 字段;结构化结果进入 `ToolAgentOutput.result`。 +9. `ToolResponse` 只返回 `result` 文本投影;再由 tool 后处理器把 `result` 解析/补全为完整 `ToolAgentOutput`,包括 `ui_hints` 等字段。 + +本次改造的核心收益: + +- 减少 worker 输出 token 成本 +- 消除 UI 在 worker / backend compiler / frontend renderer 三处漂移 +- 让工具边界对 AgentScope、skill、前端渲染都更稳定 + +## 2. Corrected Design Principles + +以下原则是本 PRD 的前提,必须严格遵守: + +1. AgentScope 原生支持 `skills`。 + - 官方 README 明确列出 built-in support for `tools, skills` + - 官方示例提供 `toolkit.register_agent_skill(...)` +2. skill 是给 agent 的能力说明与工作流知识,不是业务执行 transport。 +3. 如果工具改成 CLI,CLI 仍然必须服从 AgentScope 的工具协议,而不是项目自己随意定义。 +4. preset/runtime 参数应该以认证凭证为边界,当前项目应使用 Bearer token,并默认通过受控环境变量注入 CLI 执行环境,而不是把 DB session 直接暴露给工具调用接口。 +5. worker output 和 tool output 的协议职责必须分离,不能混成同一套字段语义。 +6. 协议文档先行,`docs/protocols/**` 仍是项目单一协议真源。 +7. 本项目的"CLI"是受限命令行工具,不是通用 bash/shell 执行能力。 + +## 3. Confirmed External Facts + +### 3.1 AgentScope 对 Skills 的官方支持 + +已确认的外部事实: + +- AgentScope 官方 README 明确写有 built-in support for `skills` +- 官方示例 `examples/functionality/agent_skill/main.py` 使用: + - `toolkit.register_agent_skill(...)` +- 官方示例同时说明: + - 使用 agent skill 时,agent 需要具备查看文本文件等基础工具能力 + - skill 目录通过 `SKILL.md` 进行渐进式披露 + +已确认的 skill 示例结构: + +- `SKILL.md` 包含 frontmatter:`name`、`description` +- skill 内容描述: + - 适用场景 + - 推荐工作流 + - 需要执行的脚本/命令 + - 何时读取补充材料 + +结论:本项目不应绕开 AgentScope skill 能力自行设计一套"伪 skill"机制。 + +### 3.2 AgentScope 工具协议的确认边界 + +已确认的 AgentScope 工具使用方式: + +- `Toolkit.register_tool_function(...)` 注册 callable function +- tool 返回 `ToolResponse` +- `ToolResponse.content` 可承载 `TextBlock` +- toolkit 支持 `preset_kwargs` + +这意味着如果本项目改成 CLI 路径,落点应是: + +- 对 agent 来说,固定暴露为单一 AgentScope tool:`project_cli` +- 对 tool 的内部实现来说,它可以调用 CLI +- CLI 的输入输出要能被稳定转换为 AgentScope `ToolResponse` + +## 4. Confirmed Repository Facts + +### 4.1 当前工具执行方式 + +当前项目仍然是 Python 函数直接注册多个工具: + +- `backend/src/core/agentscope/tools/toolkit.py` + - `TOOL_FUNCTIONS` 直接绑定: + - `calendar_read` + - `calendar_write` + - `calendar_share` + - `user_lookup` + - `memory_write` + - `memory_forget` + - `build_toolkit()` 通过 `toolkit.register_tool_function(...)` 注册这些函数 +- `backend/src/core/agentscope/tools/tool_config.py` + - 现统一使用 `enabled_skills`(`calendar|contacts|memory`)做能力白名单 +- `backend/src/core/agentscope/runtime/runner.py` + - `_build_toolkit()` 根据 `enabled_skills` 选择技能白名单 + +结论:当前实现尚未接入 AgentScope skill,也尚未接入 CLI 工具边界。 + +### 4.2 当前项目中的认证边界 + +当前项目已有明确的 Bearer token 用户解析路径: + +- `backend/src/v1/users/dependencies.py` + - `get_current_user()` 从 `Authorization: Bearer ` 解析 token + - 先走 JWT verifier,再 fallback 到 Supabase user lookup + - 最终产出 `CurrentUser` +- `backend/src/core/auth/models.py` + - `CurrentUser` 当前字段:`id`、`phone`、`role` + +结论:如果工具改为 CLI,runtime 的合理边界应是 token 或 auth credential,而不是 DB session / owner_id。token 默认通过受控环境变量注入 CLI 运行环境,CLI 内部再根据 token 推导用户身份和数据访问上下文。 + +补充约束: + +- 不能仅凭 `owner_id` 直接构造用户 Bearer token;`owner_id` 不是签名凭证。 +- automation 场景由当前 Bearer token 的签发方负责基于 `automation_jobs.owner_id` 签发短期受控凭证;若现有签发链路不支持,则补一个服务端签发函数。 +- 当前假设该签发方与用户登录 Bearer token 的签发体系一致;现有若仅支持"邮箱 + 验证码"登录签发,则在同一信任边界下新增 automation 凭证签发能力。 +- automation 受控凭证生命周期目标为 5-10 分钟,只覆盖 agent 调用工具所需窗口。 +- 该凭证的签发方式、有效期、权限边界和审计规则必须在协议文档中写清。 + +### 4.3 当前 worker output 与 UI 路径 + +当前 worker output 仍然包含 UI 描述: + +- `backend/src/schemas/agent/runtime_models.py` + - `WorkerAgentOutputRich.ui_hints` + +当前 runtime 会把 `ui_hints` 带入最终文本事件: + +- `backend/src/core/agentscope/runtime/stage_emitter.py` + - `emit_final_text_end()` 将 `ui_hints` 写入 payload + +当前对外 AG-UI payload 会把 `ui_hints` 编译为 `ui_schema`: + +- `backend/src/core/agentscope/events/agui_codec.py` + - `TEXT_MESSAGE_END` 上执行 `compile_ui_hints(...)` + +当前 history 回放同样依赖 `ui_hints -> ui_schema`: + +- `backend/src/v1/agent/utils.py` +- `backend/src/v1/agent/schemas.py` + +结论:当前 `ui_schema` 的真源是 worker 自身输出的 `ui_hints`。本次改造将把 `ui_hints` 的来源从 worker output 改为 tool output,`ui_hints → ui_schema` 的编译链路保持不变。 + +### 4.4 当前前端消费方式 + +前端当前消费的是后端下发的编译后 `ui_schema`: + +- `apps/lib/core/chat/ag_ui_event.dart` +- `apps/lib/core/chat/chat_history_repository.dart` +- `apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart` +- `apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart` + +结论:前端目前理解的是后端已编译好的 `ui_schema`。本次改造目标不是改变前端消费方式,而是改变 `ui_hints` 的来源(从 worker 改为 tool),`ui_hints → ui_schema` 编译链路保持不变。 + +### 4.5 当前 tool result 结构 + +当前 tool result 已经走 machine-oriented 路线,但结构还不完整: + +- `backend/src/core/agentscope/tools/utils/tool_response_builder.py` + - tool 最终返回 `ToolResponse` + - 当前 `ToolResponse` 中仍混入完整 tool 结构,不符合目标边界 +- `backend/src/core/agentscope/utils/parsing.py` + - 从 ToolResponse text block 中恢复 `ToolAgentOutput` +- `backend/src/core/agentscope/runtime/stage_emitter.py` + - `TOOL_CALL_RESULT` payload 包含 `tool_name`、`tool_call_args`、`status`、`result` + +当前仓库已经存在"给 agent 的文本内容"和"给事件/持久化的完整结构化对象"分离的模式: + +- `backend/src/core/agentscope/events/store.py` + - tool 消息持久化时:`content = tool_output.result` + - 完整结构化对象进入 `metadata.tool_agent_output` +- `backend/tests/unit/core/agentscope/events/test_store.py` + - 明确校验 tool message `content` 等于文本结果 + - 同时完整 `tool_agent_output` 保存在 metadata 中 + +结论:当前仓库已经证明一个正确的职责分层: + +- worker/上下文消费的应是 tool message `content` +- AG-UI 事件、持久化和前端渲染可以保留完整 `ToolAgentOutput` + +但当前仍有三个缺口: + +- `ToolAgentOutput` 还没有承载前端所需 `ui_hints` +- `ToolAgentOutput.result` 仍是字符串,还没有升级为结构化对象字段 +- `ToolResponse` 还没有收敛为只承载 `result` 文本投影,再交给 tool 后处理器生成完整 `ToolAgentOutput` + +此外,history/replay 的正确恢复方向也已经比较明确: + +- 可以像 worker output 一样,从 `message.metadata` 中恢复结构化对象 +- 对 tool 来说,恢复源应是 `metadata.tool_agent_output.ui_hints` + +## 5. Problem Statement + +当前实现存在 5 个核心问题: + +1. 工具实现直接耦合在 Python runtime 函数注册层,缺少独立协议边界。 +2. skill 尚未成为 agent 的原生能力入口,工具知识仍主要依赖 prompt 和函数 docstring。 +3. worker 仍承担 UI 描述职责,导致 `ui_hints` 占 token,且与前后端实现容易漂移。 +4. tool result 已经拥有独立结构化对象,但没有成为 UI 渲染主路径。 +5. 当前 tool output 协议没有明确区分"给 worker 推理的 content"和"给前端渲染的完整结构化输出"。 +6. 当前 CLI 工具边界尚未定义为受限命令行工具,容易和通用 shell 执行概念混淆。 + +## 6. Target Architecture + +### 6.1 总体方向 + +目标架构应遵循下面的职责分层: + +1. AgentScope skill + - 负责告诉 agent: + - 有哪些工具能力 + - 什么时候调用 + - 调用前后如何决策 + - 不直接承担业务执行 + +2. AgentScope tool + - agent 固定只看到一个工具入口:`project_cli` + - 内部执行方式为结构化 `command + subcommand + args` -> 调用项目 CLI -> 适配回 ToolResponse + +3. CLI tool + - 是业务执行边界 + - 输入输出必须稳定、结构化、可验证 + - 采用经典命令树:`command + subcommand + args` + - 不按 `skill + action` 建模 CLI + - 必须符合 AgentScope 工具协议适配需要 + - 必须是受限项目命令行工具,不提供通用 bash/shell 能力 + +4. Tool output protocol + - 保留独立的 `ToolAgentOutput` 类协议对象 + - 对 AG-UI 事件、持久化元数据、前端渲染负责 + - 可包含 `ui_hints` 这类前端渲染字段 + +5. UI contract + - 由 tool output 中的 `ui_hints` 编译生成 `ui_schema` + - `ui_schema` 成为前端渲染的输入 + - worker output 不再输出 `ui_hints` + +### 6.2 Token / 受控凭证作为 runtime 注入参数 + +本项目要求将 Bearer token 或受控服务端凭证作为 tool runtime 注入参数,而不是 DB 级参数。 + +目标边界: + +- AgentScope tool 被调用时,runtime 将认证 token 或受控服务端凭证注入 CLI 执行环境 +- CLI 接收到 token 后: + - 验证 token + - 解析用户身份 + - 推导可访问数据范围 + - 再进入 service / repository / DB 层 + +禁止继续扩大的错误边界: + +- 不应把 `AsyncSession` 直接暴露到 CLI 工具调用边界 +- 不应把 `owner_id` 当作唯一可信的输入 +- 不应让 agent 直接控制数据库上下文对象 + +当前优先方案: + +- token 不作为模型可见的业务参数字段进入 tool args schema +- token 由 runtime 以默认环境变量方式注入到 CLI 子进程 +- CLI 只读取受控环境变量,不从自然语言或 tool args 中接受任意 token 字符串 +- automation 不直接伪造用户 Bearer token;由当前 token 签发方基于 `automation_jobs.owner_id` 获取或签发 5-10 分钟短期受控凭证后注入 + +### 6.3 ToolResponse 与 ToolAgentOutput 的边界 + +本次协议边界明确如下: + +- CLI/Tool 原始执行结果先产出结构化 `result` +- AgentScope `ToolResponse` 只承载给 agent 使用的 `result` 文本投影 +- 不再把完整 `ToolAgentOutput` 直接塞进 `ToolResponse.content` +- runtime 内增加 tool 后处理器:基于 `result` 解析/补全完整 `ToolAgentOutput` +- 该后处理器负责补齐: + - `tool_name` + - `tool_call_id` + - `tool_call_args` + - `status` + - `result` + - `error` + - `ui_hints` + +这样职责分层固定为: + +- agent 只看到 `message.content` / `ToolResponse` 中的 `result` 文本投影 +- runtime / SSE / history / persistence 使用完整 `ToolAgentOutput` + +### 6.4 Worker 与 Tool 输出职责分离 + +根据当前仓库已存在的运行时与持久化逻辑,本次重构必须明确保持以下分层: + +1. worker output + - 面向 worker 最终回答 + - 不再包含 `ui_hints` + +2. tool output + - 使用 `ToolAgentOutput` 作为结构化协议对象 + - 面向 AG-UI 事件、历史持久化、前端渲染 + - 可以包含 `ui_hints` + +3. tool message content + - 面向 agent ReAct loop 上下文 + - 应是工具结构化结果的稳定文本投影 + - 不应把完整 `ToolAgentOutput` 字段直接作为下一轮 worker 输入上下文 + +当前仓库中的支持证据: + +- `backend/src/core/agentscope/events/store.py` + - tool message `content = tool_output.result` + - metadata 保留完整 `tool_agent_output` + +因此正确方向不是删除 `ToolAgentOutput`,而是: + +- 保留 `ToolAgentOutput` 作为独立 tool 协议对象 +- 只让 worker 主要消费 tool message `content` + +### 6.4 `message.content` 字段策略 + +本次不建议把 `message.content` 从文本改成通用 schema 字段。 + +原因: + +1. AgentScope/Msg 本身是通用消息抽象,不同 role 的 content 类型已经存在差异,但当前项目的 worker 推理、历史持久化、事件处理都默认把文本内容当作主信息载体。 +2. 当前仓库已经有一条可工作的分层: +- `message.content` 给 worker 推理 +- `metadata.tool_agent_output` 给结构化消费 +3. 若把 `message.content` 直接升级为 schema,会把: + - ReAct loop + - memory/context assembly + - event store + - history replay + - frontend parsing + 一起拉进大范围协议改动,收益不成比例。 + +因此推荐策略是: + +- `message.content` 继续保持文本 +- 文本内容由 `ToolAgentOutput.result` 结构化对象稳定投影生成 +- 结构化真源放在 `ToolAgentOutput.result` + +也就是说,后续语义应变成: + +- `message.content`: 给 worker 的完整 JSON 文本投影 +- `ToolAgentOutput.result`: 给系统使用的结构化对象真源 + +### 6.5 UI 真源迁移方向 + +本次改造的目标不是"前端直接裸渲染任意 CLI 参数 schema",而是: + +1. 先把 tool output 协议收敛为稳定的结构化 schema +2. 在 tool output 协议中保留前端所需 `ui_hints` +3. 再从该协议导出 AG-UI / history / frontend 统一 contract +4. AG-UI 传输时:后端 codec 将 `ui_hints` 编译为 `ui_schema` 后传输 +5. 前端渲染 `ui_schema`(复用现有 `UiSchemaRenderer`) + +**关键设计决策**: + +- `ui_hints` 是 tool 输出的描述性 UI 表示(真源) +- `ui_schema` 是编译后的渲染性 UI 表示(传输格式) +- 编译链路 `ui_hints → ui_schema` 保持不变,仅改变 `ui_hints` 的来源 +- 前端继续使用现有 `UiSchemaRenderer` 渲染 `ui_schema` + +因此本次必须避免两个错误方向: + +1. 继续保留 `worker -> ui_hints -> ui_schema`(应改为 `tool -> ui_hints -> ui_schema`) +2. 让前端直接绑定 `ui_hints` 而不是 `ui_schema` +3. 把完整 `ToolAgentOutput` 直接塞回 worker 推理输入,而不是让 worker 主要消费 tool message `content` +4. 把"项目 CLI"实现成可执行任意 shell 的通用命令入口 + +## 7. Required Refactor Requirements + +### 7.1 Skill 接入要求 + +必须: + +- 基于 AgentScope 原生 `register_agent_skill(...)` 接入 skill +- 提供项目内 skill 目录与 `SKILL.md` +- skill 内容必须包含: + - 工具能力说明 + - 何时调用哪个工具 + - 参数构造规则 + - 组合调用规则 + - 失败后的恢复策略 + +不允许: + +- 用单纯 prompt 文本替代 skill +- 自定义一套与 AgentScope skill 不兼容的 skill 机制 + +### 7.2 CLI Tool Protocol 要求 + +CLI 工具必须满足: + +- 能被 AgentScope tool wrapper 稳定调用 +- 输入参数可从当前 AgentScope tool schema 映射得到 +- 输出结果可稳定映射回 `ToolResponse` +- 输出至少包含: + - 状态 + - 结构化结果 + - 错误信息 + - 前端渲染所需的稳定字段 + +CLI 输出在进入 AgentScope tool wrapper 后,最终需要映射成两层信息: + +1. `message.content` + - 给 worker ReAct loop 使用 + - 应为结构化 `result` 的稳定文本投影 + - 由结构化 `result` 稳定投影生成 + - 默认保留完整 JSON 文本投影,不做阉割式摘要裁剪 + +2. `ToolAgentOutput` + - 给 AG-UI 事件、持久化、前端渲染使用 + - 至少包含 `tool_name`、`tool_call_id`、`tool_call_args`、`status`、`result`、`error` + - 本次应扩展支持 `ui_hints` + +这里的实现约束已经确定: + +- `ToolResponse` 只返回 `result` 文本投影 +- 完整 `ToolAgentOutput` 由 tool 后处理器在 runtime 内生成 +- `ui_hints` 由 tool 后处理器生成并写入 `ToolAgentOutput` +- `ui_hints` 经后端 codec 编译为 `ui_schema` 后传输给前端 +- worker 不参与 tool UI 生成 + +需要显式设计: + +- stdin / argv / env 何者承载输入 +- token 放在哪个输入通道 +- stdout 返回 JSON 的格式 +- 非 0 exit code 的错误语义 + +当前已确认的输入通道策略: + +- CLI 输入通道采用"两者结合" +- 默认以 `argv` 为主、`stdin` 为辅 +- token 继续通过受控环境变量注入,不进入模型可控参数 + +### 7.3 Tool Configuration 保留要求 + +以下配置能力必须保留: + +- `enabled_skills` from system agents config +- `enabled_skills` from automation jobs config + +允许变化的部分: + +- `enabled_skills` 不再映射到 Python 函数名 +- 改为映射到 CLI-backed tool capability + +### 7.4 Worker Output 精简要求 + +worker output 必须移除: + +- `ui_hints` + +worker output 保留: + +- `status` +- `answer` +- `suggested_actions` +- `error` + +是否增加新的结构化字段,必须以协议文档先定义为前提;禁止再次引入新的"隐式 UI 描述字段"。 + +### 7.5 Tool Output 协议重构要求 + +`ToolAgentOutput` 不应被删除,而应被重构为更完整的 tool 协议对象。 + +至少应满足: + +- 保留当前已有字段: + - `tool_name` + - `tool_call_id` + - `tool_call_args` + - `status` + - `result` + - `error` +- 扩展支持: + - `ui_hints` + +其中 `result` 应升级为结构化对象字段,不再以字符串为真源。 + +同时需要新增一条投影规则: + +- 从结构化 `result` 生成稳定文本投影,写入 tool message `content` +- 默认使用完整 JSON 投影,保持与 `result` 信息量一致,避免因摘要裁剪削弱模型对结构化数据的读取能力 + +同时需要固定 tool 后处理规则: + +- 后处理器以 tool 原始 `result` 为真源 +- 根据 result 解析/补齐 `ToolAgentOutput` +- `ui_hints` 由后处理器生成并写入 `ToolAgentOutput` +- worker 不参与 tool UI 生成 + +这样可以满足两条不同消费链路: + +- worker 继续消费由 `result` 投影生成的 `message.content` +- AG-UI / history / frontend 消费完整 `ToolAgentOutput` + +### 7.6 UI Contract 重构要求 + +必须完成: + +- `TEXT_MESSAGE_END` 不再依赖 worker `ui_hints` 编译 +- history assistant message 不再依赖 `metadata.agent_output.ui_hints` 编译 +- tool result 成为 UI 合约的主要输入之一 +- `TOOL_CALL_RESULT` / tool metadata / history replay 能保留 tool `ui_hints` + +AG-UI 传递策略调整为: + +- tool 事件内部携带 `ui_hints` +- 后端 codec 将 `ui_hints` 编译为 `ui_schema` +- AG-UI 对前端传输 `ui_schema` +- 前端继续复用现有 UI 渲染器渲染 `ui_schema` +- history 恢复时优先从 `message.metadata.tool_agent_output.ui_hints` 读取并编译为 `ui_schema` 后渲染 + +这里的设计结论已经确定: + +- `ui_hints` 是 tool 输出的 UI 描述(真源) +- `ui_schema` 是编译后的渲染格式(传输格式) +- 编译链路 `ui_hints → ui_schema` 保持不变 +- 前端继续使用现有 `UiSchemaRenderer` 渲染 `ui_schema` + +## 8. Scope Of Changes + +### 8.1 Protocol Docs + +必须更新: + +- `docs/protocols/ui/ui-schema.md` +- `docs/protocols/ui/data-flow.md` +- `docs/protocols/agent/sse-events.md` +- `docs/protocols/agent/run-agent-input.md` +- `docs/protocols/agent/api-endpoints.md` + +建议新增: + +- 一个工具协议文档,明确: + - CLI 输入输出约定 + - token preset 约定 + - tool result 到 UI contract 的映射边界 + +### 8.2 Backend + +主要改动点: + +- `backend/src/core/agentscope/tools/toolkit.py` +- `backend/src/core/agentscope/tools/tool_config.py` +- `backend/src/core/agentscope/runtime/runner.py` +- `backend/src/core/agentscope/runtime/stage_emitter.py` +- `backend/src/core/agentscope/events/agui_codec.py` +- `backend/src/core/agentscope/events/store.py` +- `backend/src/core/agentscope/runtime/tasks.py` +- `backend/src/core/agentscope/utils/parsing.py` +- `backend/src/schemas/agent/runtime_models.py` +- `backend/src/v1/agent/utils.py` +- `backend/src/v1/agent/schemas.py` +- 新增 skill 资产与 CLI adapter +- 新增 Python console entrypoint 与内嵌 command router + +skill 资产目录精确落点: + +- `backend/src/core/agentscope/tools/custom` + +skill 部署方式: + +- 参考 AgentScope skill 提供的原生装载/注册方法进入运行时与部署产物 +- 现有 `custom/*.py` 不再保留为最终工具实现,后续由 CLI handler / router 重写替换 + +### 8.3 Cleanup / 收尾清理 + +本次改造完成后,必须明确收尾清理以下代码与路径,避免旧协议残留: + +1. 删除或下线 worker `ui_hints` 主路径 + - `backend/src/schemas/agent/runtime_models.py` 中 worker rich output 的旧定义 + - `backend/src/core/agentscope/runtime/stage_emitter.py` 中向 `TEXT_MESSAGE_END` 写入 worker `ui_hints` 的逻辑 + - `backend/src/core/agentscope/events/agui_codec.py` 中 `TEXT_MESSAGE_END` 的 `compile_ui_hints(...)` 主路径 + - `backend/src/v1/agent/utils.py` 中 assistant history 读取 worker `ui_hints` 并编译的逻辑 + +2. 清理旧文档语义 + - `docs/protocols/ui/data-flow.md` 中"worker ui_hints 是 UI 真源"的描述 + - `docs/protocols/ui/ui-schema.md` 中把 UI compiler 绑定到 worker `ui_hints` 的描述 + - `docs/protocols/agent/sse-events.md` / `api-endpoints.md` / `run-agent-input.md` 中与旧 worker UI 路径冲突的描述 + +3. 清理测试中的旧假设 + - 依赖 worker `ui_hints` 的 runtime/event/history 单测 + - 依赖 `ToolAgentOutput.result` 为字符串真源的测试 + +4. 清理旧前端假设 + - 只接受 assistant `ui_schema` 而不理解 tool `ui_schema` 的渲染逻辑 + - 历史/事件解析中把 assistant UI 当唯一 UI 来源的路径 + +5. 清理旧工具调用边界 + - 继续把 Python runtime 函数当作唯一稳定工具边界的 registry 代码 + - 任何会把 DB session / owner_id 直接注入到 CLI 边界的适配代码 + - 旧 `custom/*.py` 直接工具实现 + +6. 清理第二条 runtime 冷路径 + - `backend/src/core/agentscope/runtime/tasks.py` 中与新 tool output/context rebuild 不一致的旧序列化路径 + - 所有会导致热路径、冷路径看到不同 tool 上下文的兼容逻辑 + +### 8.4 Frontend + +主要改动点: + +- `apps/lib/core/chat/ag_ui_event.dart` +- `apps/lib/core/chat/chat_history_repository.dart` +- `apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart`(保持不变,继续渲染 `ui_schema`) +- `apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart` + +## 9. Phased Plan + +### Phase 1. Protocol Correction + +先修正文档中的错误前提: + +- 明确 AgentScope skill 是原生能力 +- 明确 CLI 是工具实现载体,不是替代 AgentScope tool/skill +- 明确 token 环境变量注入边界 +- 明确仅 worker `ui_hints` 将被移除,tool `ui_hints` 保留在独立协议中 +- 明确 `ui_hints → ui_schema` 编译链路保持不变 + +### Phase 2. Skill And Tool Boundary Refactor + +- 接入项目内 skill +- 定义单一 `project_cli` AgentScope tool wrapper +- 使用 Pydantic 化的 `enabled_skills` 作为唯一配置字段 +- CLI 输入通道采用"两者结合",默认 `argv` 为主、`stdin` 为辅 +- 定义 Python console entrypoint 作为外部 CLI 入口 +- 定义内嵌 command router 作为唯一分发核心 +- router 只注册白名单 `command + subcommand` +- 每个命令绑定 Python handler +- handler 只调用允许的内部能力 +- 默认不开放 shell 执行能力 +- 移除旧多工具暴露实现,改为 skills 手册 + 单一 CLI 入口 + +### Phase 3. Auth-Preset Refactor + +- 将 tool preset 从 runtime DB context 改为 auth token / 受控凭证 +- CLI 内部完成身份解析与数据作用域推导 +- token 默认通过受控环境变量进入 CLI,而不是模型可控参数 +- automation 基于 `automation_jobs.owner_id` 获取或签发受控凭证,不直接伪造用户 Bearer token + +### Phase 4. UI Source Refactor + +- 删除 worker `ui_hints` 输出 +- 删除 worker `ui_hints -> ui_schema` 编译主路径 +- 将 tool `ui_hints` 纳入 `ToolAgentOutput` 协议 +- 将 `ToolAgentOutput.result` 升级为结构化对象 +- 建立 `result -> message.content` 的稳定完整 JSON 文本投影规则 +- 建立 tool output `ui_hints → ui_schema` 的编译路径 + +### Phase 4.5 Runtime Path Unification + +- 改造 `backend/src/core/agentscope/runtime/tasks.py` +- 确保热路径、冷路径、context rebuild 都使用同一套: + - `ToolAgentOutput.result` 结构化对象真源 + - `message.content` 完整 JSON 文本投影 + - `metadata.tool_agent_output` 作为历史恢复真源 +- 移除第二条旧路径,避免同一线程在不同路径下看到不同 tool 上下文 + +### Phase 5. Frontend Alignment + +- 调整事件解析和 history 解析,从 `TOOL_CALL_RESULT.ui_schema` 读取 +- 渲染逻辑保持不变,继续使用 `UiSchemaRenderer` 渲染 `ui_schema` + +## 10. Acceptance Criteria + +- [ ] PRD 与协议文档不再错误描述 AgentScope skill 能力 +- [ ] skill 通过 AgentScope 原生能力接入,而不是自定义伪 skill 机制 +- [ ] 单一 `project_cli` 能通过 AgentScope tool protocol 被稳定调用 +- [ ] 工具 runtime 以 Bearer token / 受控服务端凭证为边界 +- [ ] token/凭证 默认以受控环境变量注入 CLI 执行环境 +- [ ] CLI 能基于 token/凭证 解析用户身份与数据访问范围 +- [ ] automation 能基于 `owner_id` 由当前 token 签发方签发 5-10 分钟受控凭证,但不直接伪造用户 Bearer token +- [ ] `enabled_skills` 能驱动技能手册和允许命令域配置 +- [ ] worker output 不再包含 `ui_hints` +- [ ] `ToolAgentOutput` 保留为独立 tool 协议对象,并支持 `ui_hints` +- [ ] worker 在上下文中主要消费 tool message `content/result`,而不是完整 `ToolAgentOutput` +- [ ] `message.content` 保持文本,不升级为通用 schema 字段 +- [ ] `ToolAgentOutput.result` 升级为结构化对象真源 +- [ ] `message.content` 采用完整 JSON 文本投影,而不是裁剪摘要 +- [ ] `ToolResponse` 只承载 `result` 文本投影 +- [ ] tool 后处理器能基于 `result` 生成完整 `ToolAgentOutput`(含 `ui_hints`) +- [ ] `TEXT_MESSAGE_END` 和 assistant history 不再依赖 worker `ui_hints` 编译 UI +- [ ] history 能直接从 `metadata.tool_agent_output.ui_hints` 恢复 tool UI +- [ ] `runtime/tasks.py` 等冷/热路径已统一,不再存在第二条旧路径 +- [ ] 前端继续通过现有 UI 渲染器消费 `ui_schema` 渲染内容 +- [ ] `ui_hints → ui_schema` 编译链路保持不变,仅改变 `ui_hints` 来源 +- [ ] skill 资产目录已落在 `backend/src/core/agentscope/tools/skills` +- [ ] skill 部署/装载方式与 AgentScope 原生方法保持一致 +- [ ] CLI 输入通道已按 `argv` 主、`stdin` 辅的组合模式落地 +- [ ] 旧 `custom/*.py` 直接工具实现已被 CLI handler / router 替换 + +## 11. Open Questions + +当前 open questions 已收口,不再保留架构级未决项。 + +已确认结论: + +1. `ui_hints` 是 tool 输出的 UI 描述(真源),`ui_schema` 是编译后的渲染格式(传输格式) +2. skill 资产目录最终精确落点为 `backend/src/core/agentscope/tools/custom` +3. skill 部署方式参考 AgentScope skill 提供的原生方法 +4. CLI 输入通道采用"两者结合",默认 `argv` 为主、`stdin` 为辅 +5. history route 的 tool UI 回放从 `metadata.tool_agent_output.ui_hints` 读取并编译为 `ui_schema` +6. 旧 `custom/*.py` 不再保留,工具实现改为 CLI handler / router +7. `ToolResponse` 只返回 `result` 文本投影,完整 `ToolAgentOutput` 由 tool 后处理器生成 + +## 12. Risks + +1. 如果只把工具换成 CLI,但不先定义稳定工具协议,最终仍会把旧耦合问题原样搬过去。 +2. 如果前端直接消费 `ui_hints` 而不是 `ui_schema`,会破坏现有渲染链路。 +3. 如果把"项目 CLI"误实现为通用 shell/命令执行器,会引入提示词注入和越权风险。 +4. 如果 token/凭证 注入边界设计不严谨,容易把认证逻辑散落到多个工具实现中。 +5. 当前代码里仍有多处 broad catch,迁移时错误可能被掩盖: + - `backend/src/core/agentscope/events/agui_codec.py` + - `backend/src/v1/agent/utils.py` + - `apps/lib/core/chat/ag_ui_service.dart` + +## 13. Definition Of Done + +当以下条件全部满足时,本任务完成: + +1. 协议文档修正完毕并成为实现依据。 +2. AgentScope skill 已在项目内落地并承担工具使用知识披露。 +3. 当前工具已切换为单一 `project_cli` + skills 手册架构。 +4. token/受控凭证 已通过受控环境变量成为 CLI runtime 的认证边界。 +5. worker output 已移除 `ui_hints`。 +6. `ToolAgentOutput` 已成为独立完整的 tool 协议对象,并承载前端所需 UI hints。 +7. `ToolAgentOutput.result` 已升级为结构化对象真源。 +8. worker 仅通过完整 JSON 文本投影的 tool message `content` 获取工具结果主信息。 +9. history 能通过 `metadata.tool_agent_output.ui_hints` 编译为 `ui_schema` 恢复 tool UI。 +10. `runtime/tasks.py` 等冷/热路径已统一,不再存在第二条旧路径。 +11. AG-UI 对 tool UI 编译 `ui_hints → ui_schema` 后传输,前端继续复用现有 UI 渲染器。 +12. 旧 worker UI 编译路径和相关清理项已完成收尾。 +13. 前后端事件/history/渲染链路验证通过。 +14. `ui_hints → ui_schema` 编译链路保持不变,仅改变 `ui_hints` 来源。 +15. skill 资产已按约定落在 `backend/src/core/agentscope/tools/skills`,并按 AgentScope 原生方式装载。 +16. CLI 输入通道已按 `argv` 主、`stdin` 辅的组合模式落地。 +17. history route 已从 `metadata.tool_agent_output.ui_hints` 编译为 `ui_schema` 回放 tool UI。 +18. 旧 `custom/*.py` 直接工具实现已被 CLI handler / router 替换。 +19. `ToolResponse` 已收敛为只返回 `result` 文本投影,完整 `ToolAgentOutput` 由 tool 后处理器生成。 diff --git a/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/session-debug-tool-credential.md b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/session-debug-tool-credential.md new file mode 100644 index 0000000..9f0c9a6 --- /dev/null +++ b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/session-debug-tool-credential.md @@ -0,0 +1,96 @@ +# Debug Session: Tool Credential Injection Issue + +## Date +2026-04-22 + +## Context + +After completing the skills+CLI refactor, running live integration tests revealed tool credential injection issues. + +## Commits Made + +1. `4d55df4` - refactor: unify skills+cli runtime and streamline ag-ui flow +2. `ef931ee` - chore: clean up legacy tool/UI code paths and remove unused events +3. `91077a9` - fix: pass tool_call_id to parse_tool_agent_output for proper payload resolution + +## Test Execution + +```bash +CLI_SKILLS_LIVE_TEST=1 TEST_USER_ID="f6f4bc6b-f525-434e-81b6-38eeef9b89a8" \ + AGENT_LIVE_BASE_URL="http://localhost:5775" \ + uv run pytest backend/tests/integration/test_cli_skills_live.py::test_calendar_read_skill_queries_db -v -s +``` + +## Error Found + +From `logs/errors/worker-agent.error.log`: + +``` +"error": "tool credential not found in runtime context", +"tool_name": "project_cli" +``` + +Full stack trace shows: +1. `invoke_cli_tool` calls `_resolve_owner_id()` +2. `_resolve_owner_id()` calls `get_tool_credential()` +3. `get_tool_credential()` returns `None` +4. Raises `TokenValidationError("tool credential not found in runtime context")` + +## Root Cause Analysis + +The tool credential is set via context variable `tool_credential` but is not being injected into the runtime context before tool execution. + +### Key Files + +- `backend/src/core/auth/tool_credential_context.py` - ContextVar for tool credential +- `backend/src/core/agentscope/tools/cli/adapter.py` - Calls `get_tool_credential()` +- `backend/src/core/agentscope/runtime/runner.py` - Should inject credential before tool execution + +### Expected Flow + +1. Runner receives run request with `owner_id` +2. Runner creates tool credential using `ToolCredentialIssuer` +3. Runner sets credential via `set_tool_credential(credential)` +4. Tool execution reads via `get_tool_credential()` +5. After execution, credential is cleared + +### Missing Implementation + +The credential injection logic needs to be added to `runner.py` around the worker stage execution. + +## Secondary Error + +When tool credential fails, the error response causes a DB insert error: + +``` +invalid input for query argument $5: {'status': 'failure', ...} (expected str, got dict) +``` + +This is because `content` field receives a dict instead of str. Fixed in `store.py` by ensuring proper serialization, but the root cause is the missing credential. + +## Next Steps + +1. Find where tool credential should be set in runtime +2. Add credential issuance in runner before tool execution +3. Ensure credential is passed through task queue or generated in worker +4. Restart backend service with new code +5. Re-run integration tests + +## Database State + +- `system_agents.config.enabled_skills`: Correctly uses `["calendar", "contacts"]` +- `automation_jobs.config`: No longer has `enabled_tools` +- User ID for testing: `f6f4bc6b-f525-434e-81b6-38eeef9b89a8` + +## Files Modified + +- `backend/src/core/agentscope/runtime/stage_emitter.py` - Fixed `tool_call_id` passing +- `backend/tests/integration/test_cli_skills_live.py` - Added live integration tests + +## Remaining Work + +- [ ] Fix tool credential injection in runtime +- [ ] Verify calendar read/write works end-to-end +- [ ] Verify contacts lookup works end-to-end +- [ ] Verify memory write via automation works +- [ ] Run full test suite after fixes diff --git a/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/task.json b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/task.json new file mode 100644 index 0000000..3bae55d --- /dev/null +++ b/.trellis/tasks/04-20-refactor-tool-cli-skill-ui-schema/task.json @@ -0,0 +1,86 @@ +{ + "id": "refactor-tool-cli-skill-ui-schema", + "name": "refactor-tool-cli-skill-ui-schema", + "title": "Refactor agent tools into CLI + skill-driven UI schema flow", + "description": "Wrap current AgentScope tools as CLI commands, add a skill that teaches the agent how to invoke the CLI, replace ui_hints-driven UI generation with tool-schema-driven rendering, and remove worker ui_hints output.", + "status": "in_progress", + "dev_type": "fullstack", + "scope": "cross-domain", + "priority": "P1", + "creator": "qzl", + "assignee": "qzl", + "createdAt": "2026-04-20", + "completedAt": null, + "branch": "worktree/refactor-tool-cli-skill-ui-schema", + "base_branch": "dev", + "worktree_path": ".worktrees/refactor-tool-cli-skill-ui-schema", + "current_phase": 5, + "next_action": [ + { + "phase": 5, + "action": "check" + }, + { + "phase": 5, + "action": "implement" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [ + { + "name": "Implement CLI router and handlers", + "status": "completed" + }, + { + "name": "Create skill documentation (calendar, contacts, memory)", + "status": "completed" + }, + { + "name": "Update protocol docs (tool-protocol.md, ui-schema.md)", + "status": "completed" + }, + { + "name": "Update frontend ag_ui_event.dart for tool UI flow", + "status": "completed" + }, + { + "name": "Write backend integration tests for CLI tool flow", + "status": "completed" + }, + { + "name": "Write frontend tests for UI schema rendering", + "status": "completed" + }, + { + "name": "Verify end-to-end agent -> CLI -> UI flow", + "status": "pending" + }, + { + "name": "Remove CLI alias compatibility and enforce canonical subcommands", + "status": "completed" + }, + { + "name": "Expand ui_hints policy to canonical CRUD commands and refactor postprocessor framework", + "status": "completed" + } + ], + "children": [], + "parent": null, + "relatedFiles": [ + "backend/src/core/agentscope/tools/tool_config.py", + "backend/src/core/agentscope/tools/toolkit.py", + "backend/src/core/agentscope/runtime/stage_emitter.py", + "backend/src/schemas/agent/runtime_models.py", + "backend/src/v1/agent/utils.py", + "apps/lib/core/chat/ag_ui_event.dart", + "apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart", + "docs/protocols/ui/ui-schema.md", + "docs/protocols/ui/data-flow.md", + "docs/protocols/agent/sse-events.md" + ], + "notes": "Core refactor is complete and protocol/docs/tests are aligned. CLI now uses canonical subcommands only (no alias compatibility). ui_hints policy follows canonical CRUD commands with a common postprocessor framework.", + "meta": { + "feature_summary": "tool refactor + CLI wrapping + skill guidance + tool-schema rendered UI + worker output simplification" + } +} diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index a2e0302..f9dc460 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -52,10 +52,9 @@ android { buildTypes { release { - if (!keystorePropertiesFile.exists()) { - throw GradleException("Missing apps/android/key.properties for release signing") + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") } - signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", diff --git a/apps/lib/core/analytics/events/base_event.dart b/apps/lib/core/analytics/events/base_event.dart deleted file mode 100644 index 462f9a1..0000000 --- a/apps/lib/core/analytics/events/base_event.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class AnalyticsContext extends Equatable { - final String? networkType; - final String? osVersion; - final String? deviceModel; - final String? locale; - final String? timezone; - - const AnalyticsContext({ - this.networkType, - this.osVersion, - this.deviceModel, - this.locale, - this.timezone, - }); - - Map toJson() => { - 'network_type': networkType, - 'os_version': osVersion, - 'device_model': deviceModel, - 'locale': locale, - 'timezone': timezone, - }; - - @override - List get props => [ - networkType, - osVersion, - deviceModel, - locale, - timezone, - ]; -} - -class BaseAnalyticsEvent extends Equatable { - final String eventId; - final String eventType; - final DateTime timestamp; - final String userId; - final String deviceId; - final String sessionId; - final String platform; - final String appVersion; - final String? appBuild; - final String env; - final String? pageName; - final String? traceId; - final String? requestId; - final Map attributes; - final Map metrics; - final AnalyticsContext? context; - - const BaseAnalyticsEvent({ - required this.eventId, - required this.eventType, - required this.timestamp, - required this.userId, - required this.deviceId, - required this.sessionId, - required this.platform, - required this.appVersion, - this.appBuild, - required this.env, - this.pageName, - this.traceId, - this.requestId, - this.attributes = const {}, - this.metrics = const {}, - this.context, - }); - - Map toJson() => { - 'event_id': eventId, - 'event_type': eventType, - 'timestamp': timestamp.toUtc().toIso8601String(), - 'user_id': userId, - 'device_id': deviceId, - 'session_id': sessionId, - 'platform': platform, - 'app_version': appVersion, - 'app_build': appBuild, - 'env': env, - 'page_name': pageName, - 'trace_id': traceId, - 'request_id': requestId, - 'attributes': attributes, - 'metrics': metrics, - 'context': context?.toJson(), - }; - - @override - List get props => [ - eventId, - eventType, - timestamp, - userId, - deviceId, - sessionId, - platform, - appVersion, - appBuild, - env, - pageName, - traceId, - requestId, - attributes, - metrics, - context, - ]; -} diff --git a/apps/lib/core/analytics/events/click_event.dart b/apps/lib/core/analytics/events/click_event.dart deleted file mode 100644 index 47811c6..0000000 --- a/apps/lib/core/analytics/events/click_event.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'base_event.dart'; - -class UiClickEvent extends BaseAnalyticsEvent { - UiClickEvent({ - required super.eventId, - required super.timestamp, - required super.userId, - required super.deviceId, - required super.sessionId, - required super.platform, - required super.appVersion, - super.appBuild, - required super.env, - required super.pageName, - super.traceId, - super.requestId, - required String elementId, - String? elementType, - super.context, - }) : super( - eventType: 'ui.click', - attributes: { - 'element_id': elementId, - if (elementType != null) 'element_type': elementType, - }, - ); -} diff --git a/apps/lib/core/analytics/events/conversation_event.dart b/apps/lib/core/analytics/events/conversation_event.dart deleted file mode 100644 index e6253f3..0000000 --- a/apps/lib/core/analytics/events/conversation_event.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'base_event.dart'; - -class AgentChatCompletedEvent extends BaseAnalyticsEvent { - AgentChatCompletedEvent({ - required super.eventId, - required super.timestamp, - required super.userId, - required super.deviceId, - required super.sessionId, - required super.platform, - required super.appVersion, - super.appBuild, - required super.env, - super.pageName, - super.traceId, - super.requestId, - required String conversationId, - String? scenario, - int? messageCount, - int? responseTimeMs, - AnalyticsContext? context, - }) : super( - eventType: 'agent.chat_completed', - attributes: { - 'conversation_id': conversationId, - if (scenario != null) 'scenario': scenario, - }, - metrics: { - if (messageCount != null) 'message_count': messageCount, - if (responseTimeMs != null) 'response_time_ms': responseTimeMs, - }, - context: context, - ); -} diff --git a/apps/lib/core/analytics/events/events.dart b/apps/lib/core/analytics/events/events.dart deleted file mode 100644 index 2e9bead..0000000 --- a/apps/lib/core/analytics/events/events.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'base_event.dart'; -export 'login_event.dart'; -export 'logout_event.dart'; -export 'conversation_event.dart'; -export 'page_view_event.dart'; -export 'click_event.dart'; diff --git a/apps/lib/core/analytics/events/login_event.dart b/apps/lib/core/analytics/events/login_event.dart deleted file mode 100644 index 9da77aa..0000000 --- a/apps/lib/core/analytics/events/login_event.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'base_event.dart'; - -class SessionLoginEvent extends BaseAnalyticsEvent { - SessionLoginEvent({ - required super.eventId, - required super.timestamp, - required super.userId, - required super.deviceId, - required super.sessionId, - required super.platform, - required super.appVersion, - super.appBuild, - required super.env, - super.pageName, - super.traceId, - super.requestId, - required String method, - AnalyticsContext? context, - }) : super( - eventType: 'session.login', - attributes: {'method': method}, - context: context, - ); -} diff --git a/apps/lib/core/analytics/events/logout_event.dart b/apps/lib/core/analytics/events/logout_event.dart deleted file mode 100644 index eb26b37..0000000 --- a/apps/lib/core/analytics/events/logout_event.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'base_event.dart'; - -class SessionLogoutEvent extends BaseAnalyticsEvent { - SessionLogoutEvent({ - required super.eventId, - required super.timestamp, - required super.userId, - required super.deviceId, - required super.sessionId, - required super.platform, - required super.appVersion, - super.appBuild, - required super.env, - super.pageName, - super.traceId, - super.requestId, - String? reason, - int? sessionDurationS, - AnalyticsContext? context, - }) : super( - eventType: 'session.logout', - attributes: reason != null ? {'reason': reason} : const {}, - metrics: sessionDurationS != null - ? {'session_duration_s': sessionDurationS} - : const {}, - context: context, - ); -} diff --git a/apps/lib/core/analytics/events/page_view_event.dart b/apps/lib/core/analytics/events/page_view_event.dart deleted file mode 100644 index d2dffff..0000000 --- a/apps/lib/core/analytics/events/page_view_event.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'base_event.dart'; - -class PageViewEvent extends BaseAnalyticsEvent { - PageViewEvent({ - required super.eventId, - required super.timestamp, - required super.userId, - required super.deviceId, - required super.sessionId, - required super.platform, - required super.appVersion, - super.appBuild, - required super.env, - required super.pageName, - super.traceId, - super.requestId, - String? pageFrom, - int? stayDurationMs, - int? clickCount, - super.context, - }) : super( - eventType: 'page.view', - attributes: pageFrom != null ? {'page_from': pageFrom} : const {}, - metrics: { - if (stayDurationMs != null) 'stay_duration_ms': stayDurationMs, - if (clickCount != null) 'click_count': clickCount, - }, - ); -} diff --git a/apps/lib/core/analytics/queue/event_queue.dart b/apps/lib/core/analytics/queue/event_queue.dart deleted file mode 100644 index 5618042..0000000 --- a/apps/lib/core/analytics/queue/event_queue.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:async'; - -import '../events/events.dart'; - -class EventQueue { - final List _queue = []; - final int maxSize; - final Duration flushInterval; - final void Function(List) onFlush; - - Timer? _timer; - - EventQueue({ - this.maxSize = 50, - this.flushInterval = const Duration(seconds: 30), - required this.onFlush, - }); - - void start() { - _timer?.cancel(); - _timer = Timer.periodic(flushInterval, (_) => _tryFlush()); - } - - void stop() { - _timer?.cancel(); - _timer = null; - } - - void add(BaseAnalyticsEvent event) { - _queue.add(event); - if (_queue.length >= maxSize) { - _tryFlush(); - } - } - - void _tryFlush() { - if (_queue.isEmpty) return; - final events = List.from(_queue); - _queue.clear(); - onFlush(events); - } - - List get pendingEvents => List.unmodifiable(_queue); - - int get pendingCount => _queue.length; -} diff --git a/apps/lib/core/analytics/sender.dart b/apps/lib/core/analytics/sender.dart deleted file mode 100644 index 6a2e89e..0000000 --- a/apps/lib/core/analytics/sender.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; - -import 'events/events.dart'; - -class AnalyticsSender { - final Dio _dio; - final String endpoint; - - AnalyticsSender({required this.endpoint, Dio? dio}) : _dio = dio ?? Dio(); - - Future send(List events) async { - if (events.isEmpty) return; - - final body = {'events': events.map((e) => e.toJson()).toList()}; - - try { - await _dio.post( - endpoint, - data: jsonEncode(body), - options: Options( - headers: {'Content-Type': 'application/json'}, - sendTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - ), - ); - } on DioException catch (e) { - throw AnalyticsSendException( - 'Failed to send analytics events: ${e.message}', - events: events, - ); - } - } -} - -class AnalyticsSendException implements Exception { - final String message; - final List events; - - AnalyticsSendException(this.message, {required this.events}); - - @override - String toString() => 'AnalyticsSendException: $message'; -} diff --git a/apps/lib/core/analytics/tracker.dart b/apps/lib/core/analytics/tracker.dart deleted file mode 100644 index e736999..0000000 --- a/apps/lib/core/analytics/tracker.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:uuid/uuid.dart'; - -import 'events/events.dart'; -import 'queue/event_queue.dart'; -import 'sender.dart'; - -class AnalyticsTracker { - static AnalyticsTracker? _instance; - - late final AnalyticsSender _sender; - late final EventQueue _queue; - late final String _deviceId; - late final String _sessionId; - late final String _platform; - late final String _appVersion; - late final String? _appBuild; - late final String _env; - - String? _userId; - - AnalyticsTracker._(); - - static AnalyticsTracker get instance { - if (_instance == null) { - throw StateError('AnalyticsTracker not initialized. Call init() first.'); - } - return _instance!; - } - - static Future init({ - required String endpoint, - required String deviceId, - }) async { - if (_instance != null) return; - - final packageInfo = await PackageInfo.fromPlatform(); - - final sessionId = await _getOrCreateSessionId(); - final platform = Platform.isAndroid ? 'android' : 'ios'; - final env = kDebugMode ? 'dev' : 'prod'; - - final tracker = AnalyticsTracker._(); - tracker._sender = AnalyticsSender(endpoint: endpoint); - tracker._queue = EventQueue( - maxSize: 50, - flushInterval: const Duration(seconds: 30), - onFlush: tracker._handleFlush, - ); - tracker._deviceId = deviceId; - tracker._sessionId = sessionId; - tracker._platform = platform; - tracker._appVersion = packageInfo.version; - tracker._appBuild = packageInfo.buildNumber.isNotEmpty - ? packageInfo.buildNumber - : null; - tracker._env = env; - - tracker._queue.start(); - _instance = tracker; - } - - static Future _getOrCreateSessionId() async { - const uuid = Uuid(); - final prefs = await SharedPreferences.getInstance(); - var sessionId = prefs.getString('_analytics_session_id'); - if (sessionId == null) { - sessionId = 'sess_${uuid.v4()}'; - await prefs.setString('_analytics_session_id', sessionId); - } - return sessionId; - } - - void setUserId(String? userId) { - _userId = userId; - } - - String get userId => _userId ?? 'anonymous'; - - String get sessionId => _sessionId; - - void track(BaseAnalyticsEvent event) { - _queue.add(event); - } - - void trackLogin({ - required String method, - String? traceId, - String? requestId, - AnalyticsContext? context, - }) { - track( - SessionLoginEvent( - eventId: _generateEventId(), - timestamp: DateTime.now(), - userId: userId, - deviceId: _deviceId, - sessionId: _sessionId, - platform: _platform, - appVersion: _appVersion, - appBuild: _appBuild, - env: _env, - pageName: 'login', - traceId: traceId, - requestId: requestId, - method: method, - context: context, - ), - ); - } - - void trackLogout({ - String? reason, - int? sessionDurationS, - String? pageName, - String? traceId, - AnalyticsContext? context, - }) { - track( - SessionLogoutEvent( - eventId: _generateEventId(), - timestamp: DateTime.now(), - userId: userId, - deviceId: _deviceId, - sessionId: _sessionId, - platform: _platform, - appVersion: _appVersion, - appBuild: _appBuild, - env: _env, - pageName: pageName, - traceId: traceId, - reason: reason, - sessionDurationS: sessionDurationS, - context: context, - ), - ); - } - - void trackAgentChatCompleted({ - required String conversationId, - String? scenario, - int? messageCount, - int? responseTimeMs, - String? traceId, - String? requestId, - AnalyticsContext? context, - }) { - track( - AgentChatCompletedEvent( - eventId: _generateEventId(), - timestamp: DateTime.now(), - userId: userId, - deviceId: _deviceId, - sessionId: _sessionId, - platform: _platform, - appVersion: _appVersion, - appBuild: _appBuild, - env: _env, - pageName: 'chat', - traceId: traceId, - requestId: requestId, - conversationId: conversationId, - scenario: scenario, - messageCount: messageCount, - responseTimeMs: responseTimeMs, - context: context, - ), - ); - } - - void trackPageView({ - required String pageName, - String? pageFrom, - int? stayDurationMs, - int? clickCount, - String? traceId, - AnalyticsContext? context, - }) { - track( - PageViewEvent( - eventId: _generateEventId(), - timestamp: DateTime.now(), - userId: userId, - deviceId: _deviceId, - sessionId: _sessionId, - platform: _platform, - appVersion: _appVersion, - appBuild: _appBuild, - env: _env, - pageName: pageName, - pageFrom: pageFrom, - stayDurationMs: stayDurationMs, - clickCount: clickCount, - traceId: traceId, - context: context, - ), - ); - } - - void trackClick({ - required String pageName, - required String elementId, - String? elementType, - String? traceId, - AnalyticsContext? context, - }) { - track( - UiClickEvent( - eventId: _generateEventId(), - timestamp: DateTime.now(), - userId: userId, - deviceId: _deviceId, - sessionId: _sessionId, - platform: _platform, - appVersion: _appVersion, - appBuild: _appBuild, - env: _env, - pageName: pageName, - elementId: elementId, - elementType: elementType, - traceId: traceId, - context: context, - ), - ); - } - - String _generateEventId() { - return const Uuid().v4(); - } - - Future _handleFlush(List events) async { - try { - await _sender.send(events); - } catch (e) { - // TODO: 失败时落盘本地,下次启动重试 - debugPrint('Analytics send failed: $e'); - } - } - - void dispose() { - _queue.stop(); - } -} diff --git a/apps/lib/core/chat/ag_ui_event.dart b/apps/lib/core/chat/ag_ui_event.dart index cfbebf7..6acf0c9 100644 --- a/apps/lib/core/chat/ag_ui_event.dart +++ b/apps/lib/core/chat/ag_ui_event.dart @@ -5,11 +5,7 @@ class AgUiEventTypeWire { static const stepStarted = 'STEP_STARTED'; static const stepFinished = 'STEP_FINISHED'; static const textMessageEnd = 'TEXT_MESSAGE_END'; - static const toolCallStart = 'TOOL_CALL_START'; - static const toolCallArgs = 'TOOL_CALL_ARGS'; - static const toolCallEnd = 'TOOL_CALL_END'; static const toolCallResult = 'TOOL_CALL_RESULT'; - static const toolCallError = 'TOOL_CALL_ERROR'; } enum AgUiEventType { @@ -19,11 +15,7 @@ enum AgUiEventType { stepStarted, stepFinished, textMessageEnd, - toolCallStart, - toolCallArgs, - toolCallEnd, toolCallResult, - toolCallError, unknown, } @@ -34,11 +26,7 @@ const _wireToTypeMap = { AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted, AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished, AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd, - AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart, - AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs, - AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd, AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult, - AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError, }; const _typeToWireMap = { @@ -48,11 +36,7 @@ const _typeToWireMap = { AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted, AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished, AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd, - AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart, - AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs, - AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd, AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult, - AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError, AgUiEventType.unknown: '', }; @@ -78,11 +62,7 @@ abstract class AgUiEvent { AgUiEventType.stepStarted => StepStartedEvent.fromJson(json), AgUiEventType.stepFinished => StepFinishedEvent.fromJson(json), AgUiEventType.textMessageEnd => TextMessageEndEvent.fromJson(json), - AgUiEventType.toolCallStart => ToolCallStartEvent.fromJson(json), - AgUiEventType.toolCallArgs => ToolCallArgsEvent.fromJson(json), - AgUiEventType.toolCallEnd => ToolCallEndEvent.fromJson(json), AgUiEventType.toolCallResult => ToolCallResultEvent.fromJson(json), - AgUiEventType.toolCallError => ToolCallErrorEvent.fromJson(json), AgUiEventType.unknown => UnknownAgUiEvent(rawJson: json), }; } @@ -162,14 +142,14 @@ class TextMessageEndEvent extends AgUiEvent { required this.answer, required this.role, required this.status, - required this.uiSchema, + this.suggestedActions = const [], }) : super(type: AgUiEventType.textMessageEnd); final String messageId; final String answer; final String role; final String status; - final Map? uiSchema; + final List suggestedActions; factory TextMessageEndEvent.fromJson(Map json) => TextMessageEndEvent( @@ -177,86 +157,38 @@ class TextMessageEndEvent extends AgUiEvent { answer: _asString(json['answer']), role: _asString(json['role'], fallback: 'assistant'), status: _asString(json['status'], fallback: 'success'), - uiSchema: _asMap(json['ui_schema']), + suggestedActions: _asStringList(json['suggested_actions']), ); } -class ToolCallStartEvent extends AgUiEvent { - ToolCallStartEvent({required this.toolCallId, required this.toolCallName}) - : super(type: AgUiEventType.toolCallStart); - - final String toolCallId; - final String toolCallName; - - factory ToolCallStartEvent.fromJson(Map json) => - ToolCallStartEvent( - toolCallId: _asString(json['toolCallId']), - toolCallName: _asString(json['toolCallName']), - ); -} - -class ToolCallArgsEvent extends AgUiEvent { - ToolCallArgsEvent({required this.toolCallId, required this.args}) - : super(type: AgUiEventType.toolCallArgs); - - final String toolCallId; - final Map args; - - factory ToolCallArgsEvent.fromJson(Map json) => - ToolCallArgsEvent( - toolCallId: _asString(json['toolCallId']), - args: _asMap(json['args']) ?? const {}, - ); -} - -class ToolCallEndEvent extends AgUiEvent { - ToolCallEndEvent({required this.toolCallId}) - : super(type: AgUiEventType.toolCallEnd); - - final String toolCallId; - - factory ToolCallEndEvent.fromJson(Map json) => - ToolCallEndEvent(toolCallId: _asString(json['toolCallId'])); -} - class ToolCallResultEvent extends AgUiEvent { ToolCallResultEvent({ required this.messageId, required this.toolCallId, required this.toolName, - required this.resultSummary, + this.toolCallArgs, + this.result, required this.status, + required this.uiSchema, }) : super(type: AgUiEventType.toolCallResult); final String messageId; final String toolCallId; final String toolName; - final String resultSummary; + final Map? toolCallArgs; + final Object? result; final String status; + final Map? uiSchema; factory ToolCallResultEvent.fromJson(Map json) => ToolCallResultEvent( messageId: _asString(json['messageId']), toolCallId: _asString(json['tool_call_id']), toolName: _asString(json['tool_name']), - resultSummary: _asString(json['result']), + toolCallArgs: _asMap(json['tool_call_args']), + result: json['result'], status: _asString(json['status'], fallback: 'success'), - ); -} - -class ToolCallErrorEvent extends AgUiEvent { - ToolCallErrorEvent({required this.toolCallId, required this.error, this.code}) - : super(type: AgUiEventType.toolCallError); - - final String toolCallId; - final String error; - final String? code; - - factory ToolCallErrorEvent.fromJson(Map json) => - ToolCallErrorEvent( - toolCallId: _asString(json['toolCallId']), - error: _asString(json['error'], fallback: 'Tool call failed'), - code: json['code'] as String?, + uiSchema: _asMap(json['ui_schema']), ); } @@ -301,6 +233,7 @@ class HistoryMessage { required this.content, required this.timestamp, this.attachments = const [], + this.suggestedActions = const [], this.uiSchema, }); @@ -310,6 +243,7 @@ class HistoryMessage { final String content; final DateTime timestamp; final List attachments; + final List suggestedActions; final Map? uiSchema; factory HistoryMessage.fromJson(Map json) => HistoryMessage( @@ -319,6 +253,7 @@ class HistoryMessage { content: _asString(json['content']), timestamp: _parseTimestamp(_asString(json['timestamp'])), attachments: _parseHistoryAttachments(json['attachments']), + suggestedActions: _asStringList(json['suggestedActions']), uiSchema: _asMap(json['ui_schema']), ); } @@ -395,3 +330,14 @@ List _parseHistoryAttachments(Object? value) { ) .toList(); } + +List _asStringList(Object? value) { + if (value is! List) { + return const []; + } + return value + .whereType() + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(); +} diff --git a/apps/lib/core/chat/chat_history_repository.dart b/apps/lib/core/chat/chat_history_repository.dart index 3a9a06d..645a4f2 100644 --- a/apps/lib/core/chat/chat_history_repository.dart +++ b/apps/lib/core/chat/chat_history_repository.dart @@ -91,6 +91,7 @@ class ChatHistoryRepository extends CachedRepository { }, ) .toList(growable: false), + 'suggestedActions': message.suggestedActions, 'ui_schema': message.uiSchema, }; } diff --git a/apps/lib/core/chat/chat_list_item.dart b/apps/lib/core/chat/chat_list_item.dart index 9f4147b..49ca862 100644 --- a/apps/lib/core/chat/chat_list_item.dart +++ b/apps/lib/core/chat/chat_list_item.dart @@ -1,9 +1,7 @@ -enum ChatItemType { message, toolCall, toolResult } +enum ChatItemType { message, toolResult } enum MessageSender { user, ai } -enum ToolCallStatus { pending, executing, completed, error } - abstract class ChatListItem { String get id; DateTime get timestamp; @@ -22,6 +20,7 @@ class TextMessageItem extends ChatListItem { final bool isStreaming; final bool isLocalEcho; final List> attachments; + final List suggestedActions; TextMessageItem({ required this.id, @@ -31,6 +30,7 @@ class TextMessageItem extends ChatListItem { this.isStreaming = false, this.isLocalEcho = false, this.attachments = const [], + this.suggestedActions = const [], }); @override @@ -44,6 +44,7 @@ class TextMessageItem extends ChatListItem { bool? isStreaming, bool? isLocalEcho, List>? attachments, + List? suggestedActions, }) => TextMessageItem( id: id ?? this.id, content: content ?? this.content, @@ -52,54 +53,7 @@ class TextMessageItem extends ChatListItem { isStreaming: isStreaming ?? this.isStreaming, isLocalEcho: isLocalEcho ?? this.isLocalEcho, attachments: attachments ?? this.attachments, - ); -} - -class ToolCallItem extends ChatListItem { - @override - final String id; - final String callId; - final String toolName; - final Map args; - final ToolCallStatus status; - final String? errorMessage; - @override - final DateTime timestamp; - @override - final MessageSender sender; - - ToolCallItem({ - required this.id, - required this.callId, - required this.toolName, - required this.args, - required this.status, - this.errorMessage, - required this.timestamp, - required this.sender, - }); - - @override - ChatItemType get type => ChatItemType.toolCall; - - ToolCallItem copyWith({ - String? id, - String? callId, - String? toolName, - Map? args, - ToolCallStatus? status, - String? errorMessage, - DateTime? timestamp, - MessageSender? sender, - }) => ToolCallItem( - id: id ?? this.id, - callId: callId ?? this.callId, - toolName: toolName ?? this.toolName, - args: args ?? this.args, - status: status ?? this.status, - errorMessage: errorMessage ?? this.errorMessage, - timestamp: timestamp ?? this.timestamp, - sender: sender ?? this.sender, + suggestedActions: suggestedActions ?? this.suggestedActions, ); } diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index 90e5c8a..c0393c6 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -16,8 +16,6 @@ class Env { return 'http://localhost:5775'; } - static String get analyticsEndpoint => '$apiUrl/api/v1/analytics/events'; - static String version = '0.1.0'; static int build = 1; static String deviceId = ''; diff --git a/apps/lib/core/utils/tool_name_localizer.dart b/apps/lib/core/utils/tool_name_localizer.dart index 5cc0097..83844cd 100644 --- a/apps/lib/core/utils/tool_name_localizer.dart +++ b/apps/lib/core/utils/tool_name_localizer.dart @@ -1,21 +1,13 @@ import '../l10n/l10n.dart'; -const Map _toolNameAliases = { - 'calendar_read': 'calendar.read', - 'calendar_write': 'calendar.write', - 'calendar_share': 'calendar.share', - 'user_lookup': 'user.lookup', - 'memory_write': 'memory.write', - 'memory_forget': 'memory.forget', -}; - const List automationToolOptions = [ + 'calendar.create', 'calendar.read', - 'calendar.write', + 'calendar.update', + 'calendar.delete', 'calendar.share', - 'user.lookup', - 'memory.write', - 'memory.forget', + 'contacts.read', + 'memory.update', ]; String localizeToolName(String rawName) { @@ -23,20 +15,21 @@ String localizeToolName(String rawName) { if (normalized.isEmpty) { return rawName; } - final canonical = _toolNameAliases[normalized] ?? normalized; - switch (canonical) { + switch (normalized) { + case 'calendar.create': + return L10n.current.toolCalendarWrite; case 'calendar.read': return L10n.current.toolCalendarRead; - case 'calendar.write': + case 'calendar.update': + return L10n.current.toolCalendarWrite; + case 'calendar.delete': return L10n.current.toolCalendarWrite; case 'calendar.share': return L10n.current.toolCalendarShare; - case 'user.lookup': + case 'contacts.read': return L10n.current.toolUserLookup; - case 'memory.write': + case 'memory.update': return L10n.current.toolMemoryWrite; - case 'memory.forget': - return L10n.current.toolMemoryForget; default: return rawName; } diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index ee9de69..1421686 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -1,5 +1,4 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../core/analytics/tracker.dart'; import '../../../../core/logging/logger.dart'; import '../../data/repositories/auth_repository.dart'; import 'auth_event.dart'; @@ -8,7 +7,6 @@ import 'auth_state.dart'; class AuthBloc extends Bloc { final AuthRepository _repository; final Logger _logger = getLogger('features.auth.bloc'); - DateTime? _loginTime; AuthBloc(this._repository) : super(AuthInitial()) { on(_onStarted); @@ -23,8 +21,6 @@ class AuthBloc extends Bloc { final refreshToken = await _repository.getRefreshToken(); if (refreshToken != null) { final response = await _repository.refreshSession(refreshToken); - _loginTime = DateTime.now(); - AnalyticsTracker.instance.setUserId(response.user.id); emit( AuthAuthenticated( user: AuthUser(id: response.user.id, phone: response.user.phone), @@ -60,7 +56,6 @@ class AuthBloc extends Bloc { } void _onLoggedIn(AuthLoggedIn event, Emitter emit) { - _loginTime = DateTime.now(); _logger.info(message: 'User logged in', extra: {'user_id': event.user.id}); emit(AuthAuthenticated(user: event.user)); } @@ -69,9 +64,6 @@ class AuthBloc extends Bloc { AuthLoggedOut event, Emitter emit, ) async { - final sessionDuration = _loginTime != null - ? DateTime.now().difference(_loginTime!).inSeconds - : null; try { await _repository.deleteSession(); _logger.info(message: 'User logged out'); @@ -82,11 +74,6 @@ class AuthBloc extends Bloc { stackTrace: stackTrace, ); } finally { - AnalyticsTracker.instance.trackLogout( - reason: 'manual', - sessionDurationS: sessionDuration, - ); - _loginTime = null; emit( const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), ); @@ -97,9 +84,6 @@ class AuthBloc extends Bloc { AuthSessionInvalidated event, Emitter emit, ) async { - final sessionDuration = _loginTime != null - ? DateTime.now().difference(_loginTime!).inSeconds - : null; _logger.warning(message: 'Session invalidated by server'); try { await _repository.clearSessionLocalOnly(); @@ -110,11 +94,6 @@ class AuthBloc extends Bloc { stackTrace: stackTrace, ); } finally { - AnalyticsTracker.instance.trackLogout( - reason: 'expired', - sessionDurationS: sessionDuration, - ); - _loginTime = null; emit( const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired), ); diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart index 0092e14..90a7c70 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -6,7 +6,6 @@ import 'package:go_router/go_router.dart'; import '../../../../app/di/injection.dart'; import '../../../../app/router/app_routes.dart'; -import '../../../../core/analytics/tracker.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; @@ -70,8 +69,6 @@ class _LoginViewState extends State { final response = await cubit.submit(); if (response != null && mounted) { - AnalyticsTracker.instance.trackLogin(method: 'phone_code'); - AnalyticsTracker.instance.setUserId(response.user.id); context.read().add(AuthLoggedIn(user: response.user)); context.go(AppRoutes.homeMain); } diff --git a/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart b/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart index 22367a2..b592f67 100644 --- a/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart +++ b/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart @@ -10,11 +10,7 @@ String agUiEventLabel(AgUiEventType type) { AgUiEventType.stepStarted => l10n.agUiEventStepStarted, AgUiEventType.stepFinished => l10n.agUiEventStepFinished, AgUiEventType.textMessageEnd => l10n.agUiEventTextMessageEnd, - AgUiEventType.toolCallStart => l10n.agUiEventToolCallStart, - AgUiEventType.toolCallArgs => l10n.agUiEventToolCallArgs, - AgUiEventType.toolCallEnd => l10n.agUiEventToolCallEnd, AgUiEventType.toolCallResult => l10n.agUiEventToolCallResult, - AgUiEventType.toolCallError => l10n.agUiEventToolCallError, AgUiEventType.unknown => l10n.agUiEventUnknown, }; } diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index ff81cf3..6460a77 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -12,7 +12,6 @@ import 'package:social_app/core/chat/chat_list_item.dart'; import 'package:social_app/core/chat/chat_orchestrator.dart'; import 'package:social_app/core/chat/chat_history_repository.dart'; import 'package:social_app/core/chat/chat_timeline_reconciler.dart'; -import 'package:social_app/core/analytics/tracker.dart'; import 'package:social_app/core/l10n/l10n.dart'; import 'chat_bloc_recovery_utils.dart'; @@ -257,7 +256,17 @@ class ChatBloc extends Cubit implements ChatOrchestrator { bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) { final name = event.toolName.trim().toLowerCase(); final status = event.status.trim().toLowerCase(); - if (name != 'calendar_write') { + if (name != 'project_cli') { + return false; + } + final args = event.toolCallArgs; + if (args == null) { + return false; + } + final command = (args['command'] as String?)?.trim().toLowerCase(); + final subcommand = (args['subcommand'] as String?)?.trim().toLowerCase(); + const mutationSubcommands = {'create', 'update', 'delete'}; + if (command != 'calendar' || !mutationSubcommands.contains(subcommand)) { return false; } return status == 'success' || status == 'partial'; @@ -308,14 +317,7 @@ class ChatBloc extends Cubit implements ChatOrchestrator { messageCount: 1, responseTimeMs: responseTimeMs, ); - return; } - AnalyticsTracker.instance.trackAgentChatCompleted( - conversationId: conversationId, - scenario: 'assistant', - messageCount: 1, - responseTimeMs: responseTimeMs, - ); } void _clearRunMetrics() { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart index 028650a..97b8dc4 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart @@ -24,24 +24,13 @@ extension _ChatBlocEvents on ChatBloc { case AgUiEventType.runFinished: _trackChatCompleted(); _clearRunMetrics(); - emit( - _resetRunState().copyWith(items: _removeToolCallItems(state.items)), - ); + emit(_resetRunState()); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; _clearRunMetrics(); final isCanceledByUser = errorEvent.code == 'RUN_CANCELED'; emit( - _resetRunState( - error: isCanceledByUser ? null : errorEvent.message, - ).copyWith( - items: _markActiveToolCallsFailed( - state.items, - reason: isCanceledByUser - ? L10n.current.chatRunCanceled - : L10n.current.chatRunFailed, - ), - ), + _resetRunState(error: isCanceledByUser ? null : errorEvent.message), ); case AgUiEventType.stepStarted: _handleStepStarted(event as StepStartedEvent); @@ -49,16 +38,8 @@ extension _ChatBlocEvents on ChatBloc { _handleStepFinished(event as StepFinishedEvent); case AgUiEventType.textMessageEnd: _handleTextMessageEnd(event as TextMessageEndEvent); - case AgUiEventType.toolCallStart: - _handleToolCallStart(event as ToolCallStartEvent); - case AgUiEventType.toolCallArgs: - _handleToolCallArgs(event as ToolCallArgsEvent); - case AgUiEventType.toolCallEnd: - _handleToolCallEnd(event as ToolCallEndEvent); case AgUiEventType.toolCallResult: _handleToolCallResult(event as ToolCallResultEvent); - case AgUiEventType.toolCallError: - _handleToolCallError(event as ToolCallErrorEvent); case AgUiEventType.unknown: break; } @@ -86,17 +67,13 @@ extension _ChatBlocEvents on ChatBloc { state.items, event.messageId, event.answer, + event.suggestedActions, timestamp, ); - final uiSchema = event.uiSchema; - if (uiSchema != null) { - _upsertUiSchema(items, event.messageId, uiSchema, timestamp); - } - emit( state.copyWith( - items: _removeToolCallItems(items), + items: items, currentMessageId: null, isWaitingFirstToken: false, isStreaming: false, @@ -108,6 +85,7 @@ extension _ChatBlocEvents on ChatBloc { List items, String messageId, String content, + List suggestedActions, DateTime timestamp, ) { final result = List.from(items); @@ -117,7 +95,11 @@ extension _ChatBlocEvents on ChatBloc { if (index >= 0) { final existing = result[index] as TextMessageItem; - result[index] = existing.copyWith(content: content, isStreaming: false); + result[index] = existing.copyWith( + content: content, + isStreaming: false, + suggestedActions: suggestedActions, + ); return result; } @@ -128,21 +110,37 @@ extension _ChatBlocEvents on ChatBloc { timestamp: timestamp, sender: MessageSender.ai, isStreaming: false, + suggestedActions: suggestedActions, ), ); return result; } - void _upsertUiSchema( + void _handleToolCallResult(ToolCallResultEvent event) { + if (_shouldRefreshCalendarForTool(event)) { + unawaited(_refreshCalendarAfterToolMutation()); + } + final timestamp = DateTime.now(); + final items = List.from(state.items); + + final uiSchema = event.uiSchema; + if (uiSchema != null) { + _upsertToolResultUi(items, event.toolCallId, uiSchema, timestamp); + } + + emit(state.copyWith(items: items)); + } + + void _upsertToolResultUi( List items, - String messageId, + String toolCallId, Map uiSchema, DateTime timestamp, ) { - final uiItemId = '$messageId-ui'; + final uiItemId = '$toolCallId-ui'; final uiItem = ToolResultItem( id: uiItemId, - callId: messageId, + callId: toolCallId, uiSchema: uiSchema, timestamp: timestamp, sender: MessageSender.ai, @@ -155,113 +153,12 @@ extension _ChatBlocEvents on ChatBloc { items.add(uiItem); } - void _handleToolCallStart(ToolCallStartEvent event) { - final exists = state.items.any( - (item) => item is ToolCallItem && item.id == event.toolCallId, - ); - if (exists) { - return; - } - emit( - state.copyWith( - items: [ - ...state.items, - ToolCallItem( - id: event.toolCallId, - callId: event.toolCallId, - toolName: event.toolCallName, - args: const {}, - status: ToolCallStatus.pending, - timestamp: DateTime.now(), - sender: MessageSender.ai, - ), - ], - ), - ); - } - - void _handleToolCallArgs(ToolCallArgsEvent event) { - emit( - state.copyWith( - items: state.items.map((item) { - if (item is ToolCallItem && item.id == event.toolCallId) { - return item.copyWith(args: event.args); - } - return item; - }).toList(), - ), - ); - } - - void _handleToolCallEnd(ToolCallEndEvent event) { - emit( - state.copyWith( - items: state.items.map((item) { - if (item is ToolCallItem && item.id == event.toolCallId) { - return item.copyWith(status: ToolCallStatus.executing); - } - return item; - }).toList(), - ), - ); - } - - void _handleToolCallResult(ToolCallResultEvent event) { - if (_shouldRefreshCalendarForTool(event)) { - unawaited(_refreshCalendarAfterToolMutation()); - } - emit( - state.copyWith( - items: state.items.map((item) { - if (item is ToolCallItem && item.id == event.toolCallId) { - return item.copyWith(status: ToolCallStatus.completed); - } - return item; - }).toList(), - ), - ); - } - - void _handleToolCallError(ToolCallErrorEvent event) { - emit( - state.copyWith( - items: state.items.map((item) { - if (item is ToolCallItem && item.id == event.toolCallId) { - return item.copyWith( - status: ToolCallStatus.error, - errorMessage: event.error, - ); - } - return item; - }).toList(), - ), - ); - } - - List _removeToolCallItems(List items) { - return items.where((item) => item is! ToolCallItem).toList(); - } - - List _markActiveToolCallsFailed( - List items, { - required String reason, - }) { - return items.map((item) { - if (item is! ToolCallItem || - item.status == ToolCallStatus.error || - item.status == ToolCallStatus.completed) { - return item; - } - return item.copyWith(status: ToolCallStatus.error, errorMessage: reason); - }).toList(); - } - List _convertHistoryMessages(List messages) { final converted = []; for (final msg in messages) { final normalizedRole = msg.role.toLowerCase(); final isUser = normalizedRole == 'user'; - final isTool = normalizedRole == 'tool' || normalizedRole == 'tools'; + final isTool = normalizedRole == 'tool'; final sender = isUser ? MessageSender.user : MessageSender.ai; final attachments = msg.attachments .map( @@ -281,11 +178,12 @@ extension _ChatBlocEvents on ChatBloc { sender: sender, isLocalEcho: false, attachments: attachments, + suggestedActions: msg.suggestedActions, ), ); } - if (!isTool && msg.uiSchema != null) { + if (isTool && msg.uiSchema != null) { converted.add( ToolResultItem( id: '${msg.id}-ui', diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 67a21a0..088cd9c 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -12,7 +12,6 @@ import '../../../../app/di/injection.dart'; import '../../../../app/router/app_route_observer.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../core/l10n/l10n.dart'; -import '../../../../core/analytics/tracker.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/inbox/inbox_sync_store.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; @@ -99,8 +98,6 @@ class _HomeScreenState extends State int _previousItemCount = 0; bool _previousIsLoadingHistory = false; bool _routeAwareSubscribed = false; - late final DateTime _pageEnteredAt; - int _pageClickCount = 0; double? _historyViewportPixels; double? _historyViewportMaxExtent; final GlobalKey _inputHostKey = @@ -124,7 +121,6 @@ class _HomeScreenState extends State duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); - _pageEnteredAt = DateTime.now(); final initialUserId = widget.initialUserId?.trim(); if (initialUserId != null && initialUserId.isNotEmpty) { unawaited(_chatBloc.switchUser(initialUserId)); @@ -152,14 +148,6 @@ class _HomeScreenState extends State @override void dispose() { - final stayDurationMs = DateTime.now() - .difference(_pageEnteredAt) - .inMilliseconds; - AnalyticsTracker.instance.trackPageView( - pageName: 'home', - stayDurationMs: stayDurationMs, - clickCount: _pageClickCount, - ); _messageController.dispose(); _scrollController.removeListener(_handleScrollChanged); _scrollController.dispose(); @@ -294,15 +282,15 @@ class _HomeScreenState extends State return HomeFloatingHeader( unreadCount: _unreadCount, onTapSettings: () { - _trackClick('header_settings'); + context.push(AppRoutes.settingsMain); }, onTapCalendar: () { - _trackClick('header_calendar'); + context.push('${AppRoutes.calendarDayWeek}?from=home'); }, onTapMessages: () { - _trackClick('header_messages'); + context.push(AppRoutes.messageInviteList); }, ); @@ -375,7 +363,15 @@ class _HomeScreenState extends State padding: const EdgeInsets.only( bottom: _itemSpacing, ), - child: HomeChatItemRenderer.build(context, item), + child: HomeChatItemRenderer.build( + context, + item, + onSuggestedActionTap: (suggestion) => + _sendMessage( + context, + overrideContent: suggestion, + ), + ), ), ], ); @@ -406,7 +402,7 @@ class _HomeScreenState extends State child: HomeUnreadBadge( count: _chatUnreadBadgeCount, onTap: () { - _trackClick('unread_badge'); + _scheduleAutoScroll(animated: true); if (mounted) { setState(() => _chatUnreadBadgeCount = 0); @@ -459,7 +455,7 @@ class _HomeScreenState extends State } Future _onLoadMore(BuildContext context) async { - _trackClick('history_load_more'); + final chatBloc = context.read(); await _loadMoreHistoryPreservingViewport(chatBloc); } @@ -673,15 +669,15 @@ class _HomeScreenState extends State messageController: _messageController, onTapPlus: _isRecording ? () { - _trackClick('record_stop'); + _stopRecording(autoSendAfterTranscribe: false); } : () { - _trackClick('input_plus'); + _showBottomSheet(context); }, onStopGenerating: () { - _trackClick('stop_generating'); + _onStopGenerating(); }, onHoldToSpeakStart: _onHoldToSpeakStart, @@ -693,15 +689,6 @@ class _HomeScreenState extends State ); } - void _trackClick(String elementId) { - _pageClickCount += 1; - AnalyticsTracker.instance.trackClick( - pageName: 'home', - elementId: elementId, - elementType: 'button', - ); - } - void _removeImage(int index) { setState(() { _selectedImages.removeAt(index); diff --git a/apps/lib/features/home/presentation/screens/home_screen_interactions.dart b/apps/lib/features/home/presentation/screens/home_screen_interactions.dart index 8f0601c..9d82ae1 100644 --- a/apps/lib/features/home/presentation/screens/home_screen_interactions.dart +++ b/apps/lib/features/home/presentation/screens/home_screen_interactions.dart @@ -53,7 +53,6 @@ extension _HomeScreenInteractions on _HomeScreenState { }); try { - _trackClick('send_message'); await _chatBloc.sendMessage(content, images: images); } finally { if (mounted) { diff --git a/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart b/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart index 7e3af6a..4056eaf 100644 --- a/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart +++ b/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart @@ -4,9 +4,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:social_app/core/chat/chat_list_item.dart'; -import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../core/utils/tool_name_localizer.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/ui_schema/ui_schema_renderer.dart'; @@ -21,18 +19,28 @@ const _toolResultWidthFactor = 0.88; const _iconSize = AppSpacing.xxl; class HomeChatItemRenderer { - static Widget build(BuildContext context, ChatListItem item) { + static Widget build( + BuildContext context, + ChatListItem item, { + ValueChanged? onSuggestedActionTap, + }) { switch (item.type) { case ChatItemType.message: - return _buildMessageItem(context, item as TextMessageItem); - case ChatItemType.toolCall: - return _buildToolCallItem(context, item as ToolCallItem); + return _buildMessageItem( + context, + item as TextMessageItem, + onSuggestedActionTap: onSuggestedActionTap, + ); case ChatItemType.toolResult: return _buildToolResultItem(context, item as ToolResultItem); } } - static Widget _buildMessageItem(BuildContext context, TextMessageItem item) { + static Widget _buildMessageItem( + BuildContext context, + TextMessageItem item, { + ValueChanged? onSuggestedActionTap, + }) { final colorScheme = Theme.of(context).colorScheme; final isUser = item.sender == MessageSender.user; final maxMessageWidth = @@ -41,6 +49,11 @@ class HomeChatItemRenderer { item.attachments, ); final hasRenderableAttachments = imageAttachments.isNotEmpty; + final suggestedActionTap = onSuggestedActionTap; + final shouldRenderSuggestions = + !isUser && + item.suggestedActions.isNotEmpty && + suggestedActionTap != null; return Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end @@ -98,6 +111,29 @@ class HomeChatItemRenderer { imageAttachments: imageAttachments, ), ), + if (shouldRenderSuggestions) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: item.suggestedActions + .map( + (action) => GestureDetector( + onTap: () => suggestedActionTap(action), + child: Text( + action, + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + .toList(), + ), + ), ], ); } @@ -221,83 +257,6 @@ class HomeChatItemRenderer { ); } - static Widget _buildToolCallItem(BuildContext context, ToolCallItem item) { - final l10n = context.l10n; - final colorScheme = Theme.of(context).colorScheme; - final (statusText, statusColor, statusIcon) = switch (item.status) { - ToolCallStatus.pending => ( - l10n.homeToolPreparing, - colorScheme.onSurfaceVariant, - LucideIcons.clock, - ), - ToolCallStatus.executing => ( - l10n.homeToolExecuting, - colorScheme.primary, - LucideIcons.loader, - ), - ToolCallStatus.error => ( - item.errorMessage ?? l10n.homeToolExecutionFailed, - colorScheme.error, - LucideIcons.alertCircle, - ), - ToolCallStatus.completed => ( - l10n.homeToolCompleted, - colorScheme.tertiary, - LucideIcons.checkCircle, - ), - }; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Icon(statusIcon, size: 14, color: statusColor), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizeToolName(item.toolName), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - statusText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: statusColor, - ), - ), - ], - ), - ), - ], - ), - ); - } - static Widget _buildToolResultItem( BuildContext context, ToolResultItem item, diff --git a/apps/lib/features/settings/presentation/screens/job_detail_screen.dart b/apps/lib/features/settings/presentation/screens/job_detail_screen.dart index 94b6f3e..5639fdc 100644 --- a/apps/lib/features/settings/presentation/screens/job_detail_screen.dart +++ b/apps/lib/features/settings/presentation/screens/job_detail_screen.dart @@ -45,7 +45,7 @@ class _JobDetailScreenState extends State { String _contextSource = 'latest_chat'; String _contextWindowMode = 'day'; int _contextWindowCount = 2; - final Set _selectedTools = {'memory.write', 'memory.forget'}; + final Set _selectedTools = {'memory.update'}; ColorScheme get _colorScheme => Theme.of(context).colorScheme; diff --git a/apps/lib/main.dart b/apps/lib/main.dart index e00b31d..f3f19a6 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -3,7 +3,6 @@ import 'core/config/env.dart'; import 'core/logging/logger.dart'; import 'core/logging/log_service.dart'; import 'core/logging/error_handler.dart'; -import 'core/analytics/tracker.dart'; import 'app/di/injection.dart'; import 'app/app.dart'; @@ -18,11 +17,6 @@ void main() async { await configureDependencies(); await Env.init(); - await AnalyticsTracker.init( - endpoint: Env.analyticsEndpoint, - deviceId: Env.deviceId, - ); - getLogger( 'app', ).info(message: 'App starting...', extra: {'version': Env.version}); diff --git a/apps/test/core/chat/ag_ui_event_test.dart b/apps/test/core/chat/ag_ui_event_test.dart index 721cc28..2458d28 100644 --- a/apps/test/core/chat/ag_ui_event_test.dart +++ b/apps/test/core/chat/ag_ui_event_test.dart @@ -20,4 +20,18 @@ void main() { expect(message.timestamp.isUtc, isFalse); expect(message.timestamp, expected); }); + + test('history message parses suggested actions', () { + final raw = { + 'id': 'm2', + 'seq': 2, + 'role': 'assistant', + 'content': 'done', + 'suggestedActions': const ['查看日程', '创建会议'], + 'timestamp': '2026-03-29T16:06:27.870001+00:00', + }; + + final message = HistoryMessage.fromJson(raw); + expect(message.suggestedActions, ['查看日程', '创建会议']); + }); } diff --git a/apps/test/core/chat/ag_ui_event_tool_ui_test.dart b/apps/test/core/chat/ag_ui_event_tool_ui_test.dart new file mode 100644 index 0000000..fc5e056 --- /dev/null +++ b/apps/test/core/chat/ag_ui_event_tool_ui_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/chat/ag_ui_event.dart'; + +void main() { + group('ToolCallResultEvent', () { + test('parses ui_schema from json', () { + final json = { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_1', + 'tool_call_id': 'call_1', + 'tool_name': 'calendar_read', + 'result': '{"total": 5}', + 'status': 'success', + 'ui_schema': { + 'version': '2.0', + 'status': 'success', + 'root': { + 'type': 'stack', + 'children': [], + }, + }, + }; + + final event = ToolCallResultEvent.fromJson(json); + + expect(event.messageId, 'msg_1'); + expect(event.toolCallId, 'call_1'); + expect(event.toolName, 'calendar_read'); + expect(event.status, 'success'); + expect(event.uiSchema, isNotNull); + expect(event.uiSchema!['version'], '2.0'); + expect(event.uiSchema!['status'], 'success'); + }); + + test('handles missing ui_schema gracefully', () { + final json = { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_1', + 'tool_call_id': 'call_1', + 'tool_name': 'calendar_read', + 'result': '{"total": 5}', + 'status': 'success', + }; + + final event = ToolCallResultEvent.fromJson(json); + + expect(event.uiSchema, isNull); + }); + + test('parses partial status', () { + final json = { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_1', + 'tool_call_id': 'call_1', + 'tool_name': 'calendar_write', + 'result': '{"success": 1, "failed": 1}', + 'status': 'partial', + 'ui_schema': { + 'version': '2.0', + 'status': 'partial', + 'root': {'type': 'stack', 'children': []}, + }, + }; + + final event = ToolCallResultEvent.fromJson(json); + + expect(event.status, 'partial'); + expect(event.uiSchema!['status'], 'partial'); + }); + }); + + group('TextMessageEndEvent', () { + test('no longer includes ui_schema field', () { + final json = { + 'type': 'TEXT_MESSAGE_END', + 'messageId': 'msg_1', + 'answer': '日程查询完成', + 'role': 'assistant', + 'status': 'success', + }; + + final event = TextMessageEndEvent.fromJson(json); + + expect(event.messageId, 'msg_1'); + expect(event.answer, '日程查询完成'); + }); + + test('ignores legacy ui_schema if present', () { + final json = { + 'type': 'TEXT_MESSAGE_END', + 'messageId': 'msg_1', + 'answer': '日程查询完成', + 'role': 'assistant', + 'status': 'success', + 'ui_schema': {'version': '2.0'}, + }; + + final event = TextMessageEndEvent.fromJson(json); + + expect(event.answer, '日程查询完成'); + }); + }); + + group('HistoryMessage', () { + test('parses uiSchema from tool message metadata', () { + final json = { + 'id': 'm1', + 'seq': 1, + 'role': 'tool', + 'content': '{"total": 5}', + 'timestamp': '2026-04-21T10:00:00+08:00', + 'attachments': const [], + 'ui_schema': { + 'version': '2.0', + 'status': 'success', + 'root': { + 'type': 'stack', + 'children': [ + {'type': 'text', 'content': '找到 5 个日程', 'role': 'body'}, + ], + }, + }, + }; + + final message = HistoryMessage.fromJson(json); + + expect(message.uiSchema, isNotNull); + expect(message.uiSchema!['version'], '2.0'); + }); + + test('handles missing uiSchema gracefully', () { + final json = { + 'id': 'm1', + 'seq': 1, + 'role': 'assistant', + 'content': 'hello', + 'timestamp': '2026-04-21T10:00:00+08:00', + 'attachments': const [], + }; + + final message = HistoryMessage.fromJson(json); + + expect(message.uiSchema, isNull); + }); + }); +} diff --git a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart index d378cf3..0e69ccf 100644 --- a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart +++ b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart @@ -234,7 +234,7 @@ void main() { }); test( - 'tool calendar_write success triggers calendar refresh callback', + 'tool calendar_create success triggers calendar refresh callback', () async { final service = _FakeAgUiService(); var refreshCalls = 0; @@ -250,9 +250,11 @@ void main() { ToolCallResultEvent( messageId: 'msg-1', toolCallId: 'call-1', - toolName: 'calendar_write', - resultSummary: 'ok', + toolName: 'project_cli', + toolCallArgs: const {'command': 'calendar', 'subcommand': 'create'}, + result: const {'ok': true}, status: 'success', + uiSchema: null, ), ); await Future.delayed(Duration.zero); @@ -418,7 +420,6 @@ void main() { answer: 'hello', role: 'assistant', status: 'success', - uiSchema: null, ), ); diff --git a/apps/test/shared/widgets/ui_schema/ui_schema_tool_result_test.dart b/apps/test/shared/widgets/ui_schema/ui_schema_tool_result_test.dart new file mode 100644 index 0000000..45fb018 --- /dev/null +++ b/apps/test/shared/widgets/ui_schema/ui_schema_tool_result_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/l10n/app_localizations.dart'; +import 'package:social_app/shared/widgets/ui_schema/ui_schema_renderer.dart'; + +Map _toolResultSchema({ + required String status, + required List> children, +}) { + return { + 'version': '2.0', + 'status': status, + 'locale': 'zh-CN', + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'gap': 12, + 'appearance': 'card', + 'children': children, + }, + }; +} + +Widget _buildRendererHost(Map schema, Locale locale) { + return MaterialApp( + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Builder( + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return UiSchemaRenderer(context, colorScheme).renderSchema(schema); + }, + ), + ), + ); +} + +void main() { + testWidgets('renders tool result with success status', (tester) async { + final schema = _toolResultSchema( + status: 'success', + children: [ + {'type': 'text', 'content': '日程创建成功', 'role': 'title'}, + { + 'type': 'kv', + 'items': [ + {'key': 'title', 'label': '主题', 'value': '项目周会'}, + {'key': 'time', 'label': '时间', 'value': '2026-04-22 15:00'}, + ], + }, + ], + ); + + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('日程创建成功'), findsOneWidget); + expect(find.text('主题'), findsOneWidget); + expect(find.text('项目周会'), findsOneWidget); + }); + + testWidgets('renders tool result with partial status badge', (tester) async { + final schema = _toolResultSchema( + status: 'partial', + children: [ + { + 'type': 'stack', + 'direction': 'horizontal', + 'gap': 8, + 'children': [ + {'type': 'text', 'content': '批量操作结果', 'role': 'title'}, + {'type': 'badge', 'label': 'ui.status.warning', 'status': 'warning'}, + ], + }, + {'type': 'text', 'content': '成功 2 项,失败 1 项', 'role': 'body'}, + ], + ); + + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('批量操作结果'), findsOneWidget); + expect(find.text('提醒'), findsOneWidget); + }); + + testWidgets('renders tool result with action buttons', (tester) async { + final schema = _toolResultSchema( + status: 'success', + children: [ + {'type': 'text', 'content': '日程已创建', 'role': 'title'}, + { + 'type': 'stack', + 'direction': 'horizontal', + 'gap': 8, + 'children': [ + { + 'type': 'button', + 'label': '查看详情', + 'style': 'primary', + 'action': {'type': 'navigation', 'path': '/calendar/evt_123'}, + }, + { + 'type': 'button', + 'label': '分享', + 'style': 'secondary', + 'action': {'type': 'tool', 'toolId': 'calendar.share', 'params': {}}, + }, + ], + }, + ], + ); + + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('日程已创建'), findsOneWidget); + expect(find.text('查看详情'), findsOneWidget); + expect(find.text('分享'), findsOneWidget); + }); + + testWidgets('renders error status tool result', (tester) async { + final schema = _toolResultSchema( + status: 'error', + children: [ + {'type': 'text', 'content': '操作失败', 'role': 'title', 'status': 'error'}, + {'type': 'text', 'content': '您没有权限执行此操作', 'role': 'body', 'status': 'error'}, + { + 'type': 'button', + 'label': '重试', + 'style': 'primary', + 'action': {'type': 'tool', 'toolId': 'calendar.read', 'params': {}}, + }, + ], + ); + + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('操作失败'), findsOneWidget); + expect(find.text('您没有权限执行此操作'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + }); + + testWidgets('renders calendar event list from tool result', (tester) async { + final schema = _toolResultSchema( + status: 'success', + children: [ + {'type': 'text', 'content': '今日日程 (3)', 'role': 'title'}, + { + 'type': 'stack', + 'direction': 'vertical', + 'gap': 12, + 'children': [ + { + 'type': 'stack', + 'direction': 'vertical', + 'gap': 4, + 'appearance': 'card', + 'children': [ + {'type': 'text', 'content': '项目周会', 'role': 'subtitle'}, + {'type': 'text', 'content': '15:00 - 16:00', 'role': 'caption'}, + ], + }, + { + 'type': 'stack', + 'direction': 'vertical', + 'gap': 4, + 'appearance': 'card', + 'children': [ + {'type': 'text', 'content': '客户演示', 'role': 'subtitle'}, + {'type': 'text', 'content': '17:00 - 18:00', 'role': 'caption'}, + ], + }, + ], + }, + ], + ); + + await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh'))); + + expect(find.text('今日日程 (3)'), findsOneWidget); + expect(find.text('项目周会'), findsOneWidget); + expect(find.text('客户演示'), findsOneWidget); + }); + + testWidgets('handles null schema gracefully', (tester) async { + await tester.pumpWidget(_buildRendererHost({}, const Locale('zh'))); + + expect(find.byType(SizedBox), findsWidgets); + }); +} diff --git a/backend/src/app.py b/backend/src/app.py index 8295a04..9342295 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,7 +1,6 @@ from __future__ import annotations from contextlib import asynccontextmanager -from pathlib import Path from typing import Any, AsyncGenerator from fastapi import FastAPI, HTTPException, Request @@ -9,7 +8,6 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel -from starlette.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from core.config.settings import config @@ -61,14 +59,6 @@ app.add_middleware( ) app.include_router(mobile_router) -_analytics_web_dir = Path(__file__).resolve().parent / "v1" / "analytics" / "web" - -app.mount( - "/analytics", - StaticFiles(directory=_analytics_web_dir, html=True), - name="analytics-web", -) - logger.info( "Web application initialized", environment=config.runtime.environment, diff --git a/backend/src/core/agentscope/events/agui_codec.py b/backend/src/core/agentscope/events/agui_codec.py index a5c56b9..4194407 100644 --- a/backend/src/core/agentscope/events/agui_codec.py +++ b/backend/src/core/agentscope/events/agui_codec.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from ag_ui.core import ( BaseEvent, @@ -14,9 +14,6 @@ from ag_ui.core import ( from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints from schemas.agent.ui_hints import UiHintsPayload -if TYPE_CHECKING: - pass - _INTERNAL_TO_AGUI: dict[str, EventType] = { "run.started": EventType.RUN_STARTED, "run.finished": EventType.RUN_FINISHED, @@ -53,14 +50,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: payload = dict(event) event_type = str(payload.get("type", "")).strip().upper() if event_type == EventType.TEXT_MESSAGE_END.value: - ui_hints = payload.get("ui_hints") - if ui_hints is not None: - try: - ui_hints_payload = UiHintsPayload.model_validate(ui_hints) - ui_schema = compile_ui_hints(ui_hints_payload) - payload["ui_schema"] = ui_schema - except Exception: - pass payload.pop("ui_hints", None) for key in ( "inputTokens", @@ -71,8 +60,14 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: ): payload.pop(key, None) if event_type == EventType.TOOL_CALL_RESULT.value: + ui_hints = payload.get("ui_hints") + if ui_hints is not None: + try: + ui_hints_payload = UiHintsPayload.model_validate(ui_hints) + payload["ui_schema"] = compile_ui_hints(ui_hints_payload) + except Exception: + pass payload.pop("ui_hints", None) - payload.pop("ui_schema", None) return payload @@ -180,7 +175,14 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]: tool_result_payload["threadId"] = thread_id if isinstance(run_id, str) and run_id: tool_result_payload["runId"] = run_id - reserved = {"type", "threadId", "runId", "ui_hints", "ui_schema"} + ui_hints = data.get("ui_hints") + if ui_hints is not None: + try: + ui_hints_payload = UiHintsPayload.model_validate(ui_hints) + tool_result_payload["ui_schema"] = compile_ui_hints(ui_hints_payload) + except Exception: + pass + reserved = {"type", "threadId", "runId", "ui_hints"} tool_result_payload.update({k: v for k, v in data.items() if k not in reserved}) return tool_result_payload diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 2d668fd..dfeedab 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -9,6 +9,7 @@ from core.agentscope.caches.context_messages_cache import ( create_context_messages_cache, ) from core.agentscope.events.persistence import MessageRepository, SessionRepository +from core.agentscope.utils.parsing import project_tool_result_text from core.logging import get_logger from schemas.agent.forwarded_props import RuntimeMode from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus @@ -133,11 +134,8 @@ class SqlAlchemyEventStore: worker_output_fields = ( "status", "answer", - "key_points", - "result_type", "suggested_actions", "error", - "ui_hints", ) worker_output_payload: dict[str, object] = {} for field in worker_output_fields: @@ -168,10 +166,6 @@ class SqlAlchemyEventStore: if not isinstance(role_value, str): role_value = "assistant" role = self._resolve_role(role_value) - tool_name = self._event_value(event, "tool_name") - tool_name_value = ( - tool_name if isinstance(tool_name, str) and tool_name else None - ) locked_session = await session_repo.lock_session_for_update( session_id=session_id @@ -188,7 +182,6 @@ class SqlAlchemyEventStore: role=role, content=content, model_code=model_code if isinstance(model_code, str) else None, - tool_name=tool_name_value, metadata=metadata_model.model_dump(mode="json", exclude_none=True), input_tokens=input_tokens, output_tokens=output_tokens, @@ -331,6 +324,7 @@ class SqlAlchemyEventStore: "status": self._event_value(event, "status"), "result": self._event_value(event, "result"), "error": self._event_value(event, "error"), + "ui_hints": self._event_value(event, "ui_hints"), } try: @@ -346,7 +340,7 @@ class SqlAlchemyEventStore: ) return - content = tool_output.result + content = project_tool_result_text(tool_output.result) locked_session = await session_repo.lock_session_for_update( session_id=session_id diff --git a/backend/src/core/agentscope/prompts/__init__.py b/backend/src/core/agentscope/prompts/__init__.py index 03652c2..074dede 100644 --- a/backend/src/core/agentscope/prompts/__init__.py +++ b/backend/src/core/agentscope/prompts/__init__.py @@ -4,12 +4,10 @@ from core.agentscope.prompts.memory_prompt import ( build_work_memory_prompt, ) from core.agentscope.prompts.system_prompt import build_system_prompt -from core.agentscope.prompts.tool_prompt import build_tools_prompt __all__ = [ "build_agent_prompt", "build_user_memory_prompt", "build_work_memory_prompt", "build_system_prompt", - "build_tools_prompt", ] diff --git a/backend/src/core/agentscope/prompts/agent_prompt.py b/backend/src/core/agentscope/prompts/agent_prompt.py index 2bfa0e4..072ce8b 100644 --- a/backend/src/core/agentscope/prompts/agent_prompt.py +++ b/backend/src/core/agentscope/prompts/agent_prompt.py @@ -2,9 +2,8 @@ from __future__ import annotations import json from collections.abc import Callable -from typing import Any -from schemas.agent.runtime_models import ResultType, RouterAgentOutput, TaskType +from schemas.agent.runtime_models import RouterAgentOutput from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig @@ -17,21 +16,17 @@ def _wrap_section(section: str, content: str) -> str: return f"{start}\n{body}\n{end}" if body else f"{start}\n{end}" -def _enum_values(enum_cls: Any) -> str: - return ", ".join(item.value for item in enum_cls) - - def _config_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]: if llm_config is None: return [] context_mode = llm_config.context_messages.mode.value context_count = llm_config.context_messages.count - enabled_tools = [tool.value for tool in llm_config.enabled_tools] + enabled_skills = [skill.value for skill in llm_config.enabled_skills] return [ "[Runtime Config]", f"- context_messages.mode={context_mode}", f"- context_messages.count={context_count}", - f"- enabled_tools={','.join(enabled_tools) if enabled_tools else 'default'}", + f"- enabled_skills={','.join(enabled_skills) if enabled_skills else 'default'}", ] @@ -64,16 +59,10 @@ def _router_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]: "- Return exactly one RouterAgentOutput JSON object.", "[Responsibilities]", "- Router only: extract intent and route strategy; never answer user directly.", - "- Preserve intent in normalized_task_input.user_text; keep wording concise and faithful.", - "- Fill multimodal_summary only when image/attachment changes execution decisions.", - "- Fill normalized_task_input.context_summary with a brief description of what the provided context messages contain; this is critical for worker to understand the conversational background.", - "- Return key_entities and constraints that are execution-relevant; low confidence -> omit rather than guess.", - "- Set execution_mode by complexity: onestep / tool_assisted / multistep.", - "- Set result_typing.primary to the most suitable response shape; use clarification_request only when required info is missing.", - f"- task_typing.primary must use one TaskType enum: {_enum_values(TaskType)}.", - f"- task_typing.secondary max 3 enums: {_enum_values(TaskType)}.", - f"- result_typing.primary must use one ResultType enum: {_enum_values(ResultType)}.", - f"- result_typing.secondary max 3 enums: {_enum_values(ResultType)}.", + "- Set objective to the user's goal in a concise, faithful sentence.", + "- Set context_summary to a brief description of what context messages contain.", + "- Set requires_tool_evidence=true when the task needs tool execution to ground the answer.", + "- Set requires_tool_evidence=false when the question can be answered directly from context.", *_config_rules(llm_config), ] @@ -85,11 +74,11 @@ def _worker_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]: "- Return exactly one worker output JSON object matching the runtime-injected schema.", "[Responsibilities]", "- Worker only: execute routed objective without changing router intent.", - "- Treat router output as objective/constraints contract, not as a fully-materialized tool-args payload.", + "- Treat router output as objective contract, not as a fully-materialized tool-args payload.", "- Infer deterministic required tool arguments from contract fields, tool schema, and runtime context.", "- Ask minimal clarification only when required arguments cannot be inferred safely.", "- Ground every claim in available evidence and tool results; never fabricate execution state.", - "- Keep status/result_type/answer/key_points/suggested_actions/error internally consistent.", + "- Keep status/answer/suggested_actions/error internally consistent.", "[Schema Guidance]", "- The worker output schema is injected at runtime; follow it exactly.", "- Do not add fields that are not present in the injected schema.", @@ -107,9 +96,9 @@ def build_worker_contract_prompt(*, router_output: RouterAgentOutput) -> str: [ "[Worker Contract]", "- Keep routed objective unchanged.", - "- Use normalized_task_input as objective text.", - "- Use context_summary to understand conversational background from chat history.", - "- Use multimodal_summary/key_entities/constraints as execution evidence.", + "- Use objective as the execution target.", + "- Use context_summary to understand conversational background.", + "- When requires_tool_evidence=true, you MUST call at least one tool before answering.", "- Infer deterministic missing required tool args from evidence + tool schema.", "- Ask clarification only when safe inference is impossible.", "[RouterAgentOutput]", diff --git a/backend/src/core/agentscope/prompts/route_prompt.py b/backend/src/core/agentscope/prompts/route_prompt.py deleted file mode 100644 index 5d369d4..0000000 --- a/backend/src/core/agentscope/prompts/route_prompt.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from functools import lru_cache -from pathlib import Path -from typing import Any, ClassVar - -import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError - - -class FrontendRoute(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - route_id: str - path: str - description: str - category: str - auth_required: bool - path_params: list[str] = Field(default_factory=list) - query_params: list[str] = Field(default_factory=list) - - -class FrontendRouteCatalog(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - version: str - routes: list[FrontendRoute] - - -def _default_catalog_path() -> Path: - return ( - Path(__file__).resolve().parents[2] - / "config" - / "static" - / "route" - / "frontend_routes.yaml" - ) - - -@lru_cache(maxsize=1) -def load_frontend_routes_catalog() -> FrontendRouteCatalog: - path = _default_catalog_path() - with path.open("r", encoding="utf-8") as file: - loaded: Any = yaml.safe_load(file) or {} - - if not isinstance(loaded, dict): - raise ValueError(f"Invalid frontend routes catalog format: {path}") - - try: - return FrontendRouteCatalog.model_validate(loaded) - except ValidationError as exc: - raise ValueError(f"Invalid frontend routes catalog data: {path}") from exc - - -def build_frontend_route_prompt() -> str: - catalog = load_frontend_routes_catalog() - - lines = [ - "[Frontend Route Catalog]", - f"version={catalog.version}", - "rules: use listed route_id only; output concrete path; no placeholders; no query in path; put query in params; params scalar only.", - "ROUTES:", - ] - - for route in catalog.routes: - path_params = ", ".join(route.path_params) if route.path_params else "none" - query_params = ", ".join(route.query_params) if route.query_params else "none" - lines.append( - "- " - f"route_id={route.route_id}; " - f"path={route.path}; " - f"path_params={path_params}; " - f"query_params={query_params}" - ) - - return "\n".join(lines) diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index a9228d3..0294ce6 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -2,10 +2,9 @@ from __future__ import annotations import json from datetime import datetime, timezone -from typing import Any, Sequence +from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from ag_ui.core.types import Tool from core.agentscope.prompts.agent_prompt import ( build_agent_prompt, ) @@ -13,8 +12,6 @@ from core.agentscope.prompts.memory_prompt import ( build_user_memory_prompt, build_work_memory_prompt, ) -from core.agentscope.prompts.route_prompt import build_frontend_route_prompt -from core.agentscope.prompts.tool_prompt import build_tools_prompt from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig from schemas.agent.forwarded_props import ClientTimeContext from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent @@ -25,7 +22,6 @@ def _wrap_section(section: str, content: str) -> str: marker_map = { "env": ("", ""), "identity": ("", ""), - "route": ("", ""), "schema": ("", ""), "safety": ("", ""), "output": ("", ""), @@ -200,10 +196,6 @@ def _build_output_rules() -> str: ) -def _build_route_section() -> str: - return _wrap_section("route", build_frontend_route_prompt()) - - def build_system_prompt( *, agent_type: AgentType, @@ -212,12 +204,9 @@ def build_system_prompt( now_utc: datetime, runtime_client_time: ClientTimeContext | None = None, extra_context: str | None = None, - tools: Sequence[Tool | dict[str, Any]] | None = None, user_memory: UserMemoryContent | None = None, work_memory: WorkProfileContent | None = None, ) -> str: - include_route_section = agent_type == AgentType.WORKER - if agent_type == AgentType.ROUTER: memory_prompt = build_user_memory_prompt(user_memory=user_memory) else: @@ -231,13 +220,11 @@ def build_system_prompt( runtime_client_time=runtime_client_time, extra_context=extra_context, ), - _build_route_section() if include_route_section else None, _build_safety_section(), build_agent_prompt( agent_type=agent_type, llm_config=llm_config, ), - build_tools_prompt(tools=tools) if tools else None, memory_prompt, _build_output_rules(), ] diff --git a/backend/src/core/agentscope/prompts/tool_prompt.py b/backend/src/core/agentscope/prompts/tool_prompt.py deleted file mode 100644 index 2bf4bb9..0000000 --- a/backend/src/core/agentscope/prompts/tool_prompt.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, Iterable - -from ag_ui.core.types import Tool - - -def _wrap_section(section: str, content: str) -> str: - marker_map = { - "tools": ("", ""), - } - start, end = marker_map[section] - body = content.strip() - return f"{start}\n{body}\n{end}" if body else f"{start}\n{end}" - - -def build_tools_prompt( - *, - tools: Iterable[Tool | dict[str, Any]], -) -> str: - lines: list[str] = [] - lines.append("[Available Tools]") - - for item in tools: - if isinstance(item, dict): - name = str(item.get("name") or "") - description = str(item.get("description") or "") - parameters = item.get("parameters") - parameters = parameters if isinstance(parameters, dict) else {} - else: - name = item.name - description = item.description or "" - parameters = item.parameters or {} - lines.append(f"- {name}: {description}") - lines.append( - " - args_schema: " - + json.dumps(parameters, ensure_ascii=True, separators=(",", ":")) - ) - - lines.append("Note: tool arguments must strictly match args_schema.") - return _wrap_section("tools", "\n".join(lines)) diff --git a/backend/src/core/agentscope/runtime/json_react_agent.py b/backend/src/core/agentscope/runtime/json_react_agent.py index 1b48fc3..2f7c066 100644 --- a/backend/src/core/agentscope/runtime/json_react_agent.py +++ b/backend/src/core/agentscope/runtime/json_react_agent.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal from agentscope.agent import ReActAgent from agentscope.message import Msg @@ -15,11 +15,14 @@ class JsonReActAgent(ReActAgent): *, emitter: Any = None, finalize_retries: int = 2, + force_tool_on_first_reasoning: bool = False, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._pipeline_emitter = emitter self._finalize_retries = max(finalize_retries, 0) + self._force_tool_on_first_reasoning = force_tool_on_first_reasoning + self._first_reasoning_done = False self.set_console_output_enabled(False) async def print(self, msg: Msg, last: bool = True, speech: Any = None) -> None: @@ -27,6 +30,15 @@ class JsonReActAgent(ReActAgent): if self._pipeline_emitter is not None: await self._pipeline_emitter.handle_print(msg=msg, last=last) + async def _reasoning( + self, + tool_choice: Literal["auto", "none", "required"] | None = None, + ) -> Msg: + if self._force_tool_on_first_reasoning and not self._first_reasoning_done: + self._first_reasoning_done = True + tool_choice = "required" + return await super()._reasoning(tool_choice) + async def reply_json( self, msg: Msg | list[Msg] | None, diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 71e0eb1..a5b67ba 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -5,7 +5,6 @@ import contextlib from dataclasses import dataclass from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Awaitable, Callable -from uuid import UUID from ag_ui.core.types import RunAgentInput from agentscope.formatter import OpenAIChatFormatter @@ -18,12 +17,16 @@ from core.agentscope.schemas.agui_input import extract_latest_user_payload from core.agentscope.runtime.json_react_agent import JsonReActAgent from core.agentscope.runtime.model_tracking import TrackingChatModel from core.agentscope.runtime.stage_emitter import PipelineStageEmitter -from core.agentscope.tools.tool_config import AgentTool, resolve_tool_function_names from core.agentscope.tools.toolkit import build_toolkit from core.agentscope.utils import ( finalize_json_response, patch_agentscope_json_repair_compat, ) +from core.auth.credential_issuer import create_credential_issuer +from core.auth.tool_credential_context import ( + set_tool_credential, + reset_tool_credential, +) from core.config.settings import config from core.db.session import AsyncSessionLocal from models.llm import Llm @@ -38,8 +41,8 @@ from schemas.agent.forwarded_props import ( from schemas.agent.runtime_models import ( RouterAgentOutput, WorkerAgentOutputLite, - resolve_worker_output_model, ) +from schemas.agent.skill_config import ProjectCliCommand, SkillName from schemas.agent.system_agent import ( AgentType, SystemAgentLLMConfig, @@ -83,7 +86,6 @@ class AgentScopeRunner: work_memory: WorkProfileContent | None = None, cancel_checker: Callable[[], Awaitable[bool]] | None = None, ) -> dict[str, Any]: - owner_id = UUID(user_context.id) runtime_client_time = self._resolve_runtime_client_time(run_input=run_input) runtime_mode = self._resolve_runtime_mode(run_input=run_input) stop_cancel_watch = asyncio.Event() @@ -110,9 +112,8 @@ class AgentScopeRunner: agent_type=AgentType.WORKER, ) worker_toolkit = self._build_toolkit( - session=session, - owner_id=owner_id, - enabled_tools=runtime_config.enabled_tools, + enabled_skills=runtime_config.enabled_skills, + allowed_commands=runtime_config.allowed_commands, ) router_output = await self._execute_router_step( @@ -178,19 +179,14 @@ class AgentScopeRunner: def _build_toolkit( self, *, - session: AsyncSession, - owner_id: UUID, - enabled_tools: list[AgentTool], + enabled_skills: list[SkillName], + allowed_commands: list[ProjectCliCommand], ) -> Any: - tool_names = ( - sorted(resolve_tool_function_names(set(enabled_tools))) - if enabled_tools - else [] - ) + enabled_skill_names = {str(skill.value) for skill in enabled_skills} + allowed_command_names = {str(command.value) for command in allowed_commands} return build_toolkit( - session=session, - owner_id=owner_id, - enabled_tool_names=set(tool_names) if tool_names else None, + enabled_skill_names=enabled_skill_names if enabled_skill_names else None, + allowed_commands=allowed_command_names if allowed_command_names else None, ) async def _load_stage_config( @@ -279,7 +275,6 @@ class AgentScopeRunner: runtime_mode: RuntimeMode, work_memory: WorkProfileContent | None, ) -> WorkerAgentOutputLite: - worker_output_model = resolve_worker_output_model(router_output.execution_mode) await self._emit_step_event( pipeline=pipeline, run_input=run_input, @@ -295,13 +290,14 @@ class AgentScopeRunner: toolkit=toolkit, run_input=run_input, stage_config=stage_config, - worker_output_model=worker_output_model, + worker_output_model=WorkerAgentOutputLite, pipeline=pipeline, runtime_client_time=runtime_client_time, runtime_mode=runtime_mode, work_memory=work_memory, + requires_tool_evidence=router_output.requires_tool_evidence, ) - worker_output = worker_output_model.model_validate(worker_result.payload) + worker_output = WorkerAgentOutputLite.model_validate(worker_result.payload) await self._emit_step_event( pipeline=pipeline, run_input=run_input, @@ -338,7 +334,6 @@ class AgentScopeRunner: user_context=user_context, now_utc=datetime.now(timezone.utc), runtime_client_time=runtime_client_time, - tools=None, user_memory=user_memory, ), "system", @@ -400,57 +395,68 @@ class AgentScopeRunner: runtime_client_time: ClientTimeContext | None, runtime_mode: RuntimeMode, work_memory: WorkProfileContent | None, + requires_tool_evidence: bool = False, ) -> StageExecutionResult: - tracking_model = self._build_model(stage_config=stage_config) - emitter = PipelineStageEmitter( - pipeline=pipeline, - session_id=run_input.thread_id, - run_id=run_input.run_id, - stage=stage_config.agent_type.value, - runtime_mode=runtime_mode.value, - emit_text_events=True, - emit_tool_events=True, + issuer = create_credential_issuer() + credential = issuer.issue( + owner_id=str(user_context.id), + mode=runtime_mode.value, ) - agent = self._build_agent( - agent_name=stage_config.agent_type.value, - system_prompt=build_system_prompt( - agent_type=stage_config.agent_type, - llm_config=stage_config.llm_config, - user_context=user_context, - now_utc=datetime.now(timezone.utc), - runtime_client_time=runtime_client_time, - extra_context=stage_config.extra_context, - tools=None, - work_memory=work_memory, - ), - toolkit=toolkit, - model=tracking_model, - emitter=emitter, - ) - async with self._active_agent_lock: - self._active_agent = agent + credential_token = set_tool_credential(credential) + try: - response_msg = await agent.reply_json( - input_messages, output_model=worker_output_model + tracking_model = self._build_model(stage_config=stage_config) + emitter = PipelineStageEmitter( + pipeline=pipeline, + session_id=run_input.thread_id, + run_id=run_input.run_id, + stage=stage_config.agent_type.value, + runtime_mode=runtime_mode.value, + emit_text_events=True, + emit_tool_events=True, + ) + agent = self._build_agent( + agent_name=stage_config.agent_type.value, + system_prompt=build_system_prompt( + agent_type=stage_config.agent_type, + llm_config=stage_config.llm_config, + user_context=user_context, + now_utc=datetime.now(timezone.utc), + runtime_client_time=runtime_client_time, + extra_context=stage_config.extra_context, + work_memory=work_memory, + ), + toolkit=toolkit, + model=tracking_model, + emitter=emitter, + force_tool_on_first_reasoning=requires_tool_evidence, + ) + async with self._active_agent_lock: + self._active_agent = agent + try: + response_msg = await agent.reply_json( + input_messages, output_model=worker_output_model + ) + finally: + async with self._active_agent_lock: + if self._active_agent is agent: + self._active_agent = None + worker_payload = worker_output_model.model_validate(response_msg.metadata or {}) + response_metadata = self._llm_pricing_service.build_usage_metadata( + model=stage_config.model_code, + usage_summary=tracking_model.usage_summary(), + ) + await emitter.emit_final_text_end( + worker_output=worker_payload.model_dump(mode="json", exclude_none=True), + response_metadata=response_metadata, + ) + return StageExecutionResult( + message=response_msg, + payload=worker_payload.model_dump(mode="json", exclude_none=True), + response_metadata=response_metadata, ) finally: - async with self._active_agent_lock: - if self._active_agent is agent: - self._active_agent = None - worker_payload = worker_output_model.model_validate(response_msg.metadata or {}) - response_metadata = self._llm_pricing_service.build_usage_metadata( - model=stage_config.model_code, - usage_summary=tracking_model.usage_summary(), - ) - await emitter.emit_final_text_end( - worker_output=worker_payload.model_dump(mode="json", exclude_none=True), - response_metadata=response_metadata, - ) - return StageExecutionResult( - message=response_msg, - payload=worker_payload.model_dump(mode="json", exclude_none=True), - response_metadata=response_metadata, - ) + reset_tool_credential(credential_token) def _build_worker_input_messages( self, @@ -494,6 +500,7 @@ class AgentScopeRunner: toolkit: Any, model: TrackingChatModel, emitter: PipelineStageEmitter | None = None, + force_tool_on_first_reasoning: bool = False, ) -> JsonReActAgent: return JsonReActAgent( name=agent_name, @@ -503,6 +510,7 @@ class AgentScopeRunner: toolkit=toolkit, memory=InMemoryMemory(), emitter=emitter, + force_tool_on_first_reasoning=force_tool_on_first_reasoning, ) async def _emit_step_event( diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py index c8a157c..4eacd8b 100644 --- a/backend/src/core/agentscope/runtime/stage_emitter.py +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -6,6 +6,9 @@ from uuid import uuid4 from agentscope.message import Msg from core.agentscope.utils import parse_tool_agent_output +from core.logging import get_logger + +_logger = get_logger("core.agentscope.runtime.stage_emitter") class PipelineLike(Protocol): @@ -34,7 +37,6 @@ class PipelineStageEmitter: self._emitted_tool_calls: set[str] = set() self._emitted_tool_results: set[str] = set() self.latest_text_message_id: str | None = None - self.latest_text: str = "" async def handle_print(self, *, msg: Msg, last: bool) -> None: del last @@ -58,15 +60,10 @@ class PipelineStageEmitter: "stage": self._stage, "status": worker_output.get("status"), "answer": worker_output.get("answer", ""), - "key_points": worker_output.get("key_points", []), - "result_type": worker_output.get("result_type"), "suggested_actions": worker_output.get("suggested_actions", []), "error": worker_output.get("error"), **response_metadata, } - ui_hints = worker_output.get("ui_hints") - if ui_hints is not None: - payload["ui_hints"] = ui_hints await self._emit("TEXT_MESSAGE_END", payload) async def _emit_text_events_from_msg(self, msg: Msg) -> None: @@ -74,7 +71,6 @@ class PipelineStageEmitter: if not text: return self.latest_text_message_id = str(msg.id) - self.latest_text = text async def _emit_tool_events_from_msg(self, msg: Msg) -> None: for block in msg.get_content_blocks("tool_use"): @@ -103,8 +99,16 @@ class PipelineStageEmitter: tool_call_id = str(block.get("id", "")).strip() if not tool_call_id or tool_call_id in self._emitted_tool_results: continue - tool_output = parse_tool_agent_output(block.get("output")) + tool_output = parse_tool_agent_output( + block.get("output"), + tool_call_id=tool_call_id, + ) if tool_output is None: + _logger.warning( + "tool_result_block_skipped", + tool_call_id=tool_call_id, + output_type=type(block.get("output")).__name__, + ) continue payload = { "messageId": str(msg.id), @@ -116,6 +120,8 @@ class PipelineStageEmitter: "status": tool_output.status.value, "result": tool_output.result, } + if tool_output.ui_hints is not None: + payload["ui_hints"] = tool_output.ui_hints if tool_output.error: payload["error"] = tool_output.error.model_dump(mode="json") diff --git a/backend/src/core/agentscope/tools/cli/__init__.py b/backend/src/core/agentscope/tools/cli/__init__.py new file mode 100644 index 0000000..d7a83e5 --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/__init__.py @@ -0,0 +1,3 @@ +from core.agentscope.tools.cli.adapter import invoke_cli_tool as invoke_cli_tool +from core.agentscope.tools.cli.handlers import build_router as build_router +from core.agentscope.tools.cli.router import CommandRouter as CommandRouter \ No newline at end of file diff --git a/backend/src/core/agentscope/tools/cli/adapter.py b/backend/src/core/agentscope/tools/cli/adapter.py new file mode 100644 index 0000000..4679e4d --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/adapter.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +from typing import Any + +from agentscope.tool import ToolResponse +from agentscope.message import TextBlock + +from core.agentscope.tools.cli.handlers import build_router +from core.agentscope.tools.cli.models import CliCommand +from core.agentscope.tools.cli.router import CommandRouter +from core.agentscope.tools.tool_call_context import ( + get_current_tool_call_id, + store_tool_agent_output, +) +from core.agentscope.utils.parsing import project_tool_result_text +from core.auth.credential_issuer import create_credential_issuer +from core.auth.jwt_verifier import TokenValidationError +from core.auth.tool_credential_context import get_tool_credential +from core.logging import get_logger +from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus + +logger = get_logger("core.agentscope.tools.cli_adapter") + +_CACHED_ROUTER: CommandRouter | None = None + + +def _get_router() -> CommandRouter: + global _CACHED_ROUTER + if _CACHED_ROUTER is None: + _CACHED_ROUTER = build_router() + return _CACHED_ROUTER + + +def _resolve_owner_id() -> str: + credential = get_tool_credential() + if not credential: + raise TokenValidationError("tool credential not found in runtime context") + issuer = create_credential_issuer() + claims = issuer.verify(credential) + owner_id = claims.get("sub") + if not isinstance(owner_id, str) or not owner_id.strip(): + raise TokenValidationError("tool credential has no valid subject") + return owner_id + + +async def invoke_cli_tool( + *, + tool_name: str, + tool_call_args: dict[str, Any], + allowed_commands: set[str] | None = None, +) -> ToolResponse: + command = str(tool_call_args.get("command", "")).strip() + subcommand = str(tool_call_args.get("subcommand", "")).strip() + args = tool_call_args.get("args") + if isinstance(args, str): + try: + parsed_args = json.loads(args) + except (json.JSONDecodeError, ValueError): + parsed_args = None + if isinstance(parsed_args, dict): + args = parsed_args + if not isinstance(args, dict): + args = {} + + tool_call_args = { + **tool_call_args, + "subcommand": subcommand, + "args": args, + } + + if tool_name != "project_cli": + return _build_error( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="UNKNOWN_TOOL", + message=f"unsupported tool: {tool_name}", + ) + if not command or not subcommand: + return _build_error( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="INVALID_ARGUMENT", + message="command and subcommand are required", + ) + router = _get_router() + + if allowed_commands is not None and command not in allowed_commands: + return _build_error( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="COMMAND_NOT_ALLOWED", + message=f"command not enabled: {command}", + ) + + if (command, subcommand) not in router.command_pairs: + return _build_error( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="UNKNOWN_COMMAND", + message=f"unknown command: {command} {subcommand}", + ) + + try: + owner_id = _resolve_owner_id() + except TokenValidationError as exc: + logger.error("Tool credential verification failed", tool_name=tool_name, error=str(exc)) + return _build_error( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="CREDENTIAL_INVALID", + message=str(exc), + ) + + request = CliCommand( + command=command, + subcommand=subcommand, + args=args, + owner_id=owner_id, + ) + + try: + cli_result = await router.dispatch(request) + except Exception as exc: + logger.error("CLI dispatch failed", tool_name=tool_name, error=str(exc)) + return _build_error( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="CLI_DISPATCH_ERROR", + message=str(exc), + ) + + status = ToolStatus.SUCCESS if cli_result.ok else ToolStatus.FAILURE + error_info = cli_result.error + result = { + "command": cli_result.command, + "subcommand": cli_result.subcommand, + "data": cli_result.data, + } + + tool_call_id = get_current_tool_call_id(tool_name=tool_name) + output = ToolAgentOutput( + tool_name=tool_name, + tool_call_id=tool_call_id, + tool_call_args=tool_call_args, + status=status, + result=result, + error=error_info, + ) + + from core.agentscope.tools.tool_postprocessor import postprocess_tool_output + processed = postprocess_tool_output(output) + + payload = processed.model_dump(mode="json", exclude_none=True) + store_tool_agent_output(tool_call_id=processed.tool_call_id, payload=payload) + + return ToolResponse( + content=[ + TextBlock( + type="text", + text=project_tool_result_text(processed.result), + ) + ] + ) + + +def _build_error( + *, + tool_name: str, + tool_call_args: dict[str, Any] | None, + code: str, + message: str, +) -> ToolResponse: + tool_call_id = get_current_tool_call_id(tool_name=tool_name) + output = ToolAgentOutput( + tool_name=tool_name, + tool_call_id=tool_call_id, + tool_call_args=tool_call_args, + status=ToolStatus.FAILURE, + result={"status": "failure", "code": code, "message": message}, + error=ErrorInfo(code=code, message=message, retryable=False), + ) + + from core.agentscope.tools.tool_postprocessor import postprocess_tool_output + processed = postprocess_tool_output(output) + + payload = processed.model_dump(mode="json", exclude_none=True) + store_tool_agent_output(tool_call_id=processed.tool_call_id, payload=payload) + return ToolResponse( + content=[ + TextBlock( + type="text", + text=project_tool_result_text(processed.result), + ) + ] + ) diff --git a/backend/src/core/agentscope/tools/cli/handler_calendar.py b/backend/src/core/agentscope/tools/cli/handler_calendar.py new file mode 100644 index 0000000..9b62857 --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/handler_calendar.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any +from uuid import UUID +from zoneinfo import ZoneInfo + +from core.agentscope.tools.cli.models import CliCommand, CliCommandResult +from core.agentscope.tools.utils.calendar_domain import ( + build_schedule_metadata, + create_schedule_service, + map_calendar_exception, + merge_schedule_metadata_for_update, + parse_iso_datetime, + schedule_event_to_dict, +) +from schemas.agent.runtime_models import ErrorInfo +from schemas.enums import ScheduleItemStatus +from v1.schedule_items.schemas import ( + ScheduleItemCreateRequest, + ScheduleItemListRequest, + ScheduleItemShareRequest, + ScheduleItemUpdateRequest, +) + + +async def handle_calendar_read(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + parsed_start, parsed_end, read_error = _resolve_read_range(request) + if read_error is not None: + return _fail(request=request, code="INVALID_ARGUMENT", message=read_error) + if parsed_start is None or parsed_end is None: + return _fail( + request=request, + code="INVALID_ARGUMENT", + message="start_at and end_at are required", + ) + if parsed_start >= parsed_end: + return _fail( + request=request, + code="INVALID_ARGUMENT", + message="start_at must be before end_at", + ) + + async with AsyncSessionLocal() as session: + service = create_schedule_service(session, UUID(request.owner_id)) + list_request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end) + items = await service.list_by_date_range(list_request) + event_items = [schedule_event_to_dict(item) for item in items] + return CliCommandResult( + ok=True, + command="calendar", + subcommand="read", + data={"total": len(event_items), "items": event_items}, + ) + + +async def handle_calendar_create(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + async with AsyncSessionLocal() as session: + service = create_schedule_service(session, UUID(request.owner_id)) + try: + result_item = await _create_event(service, request.args) + event_id = str(result_item.get("eventId") or "") + return CliCommandResult( + ok=True, + command=request.command, + subcommand=request.subcommand, + data={ + "status": "success", + "success": 1, + "failed": 0, + "ids": [event_id] if event_id else [], + "results": [result_item], + }, + ) + except Exception as exc: + code, message, retryable = map_calendar_exception(exc) + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + data={ + "status": "failure", + "success": 0, + "failed": 1, + "ids": [], + "results": [ + { + "action": "create", + "status": "failure", + "eventId": "", + "code": code, + "message": message, + } + ], + }, + error=ErrorInfo(code=code, message=message, retryable=retryable), + ) + + +async def handle_calendar_update(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + async with AsyncSessionLocal() as session: + service = create_schedule_service(session, UUID(request.owner_id)) + event_id = str(request.args.get("event_id") or "").strip() + try: + result_item = await _update_event(service, request.args) + event_id = str(result_item.get("eventId") or event_id) + return CliCommandResult( + ok=True, + command=request.command, + subcommand=request.subcommand, + data={ + "status": "success", + "success": 1, + "failed": 0, + "ids": [event_id] if event_id else [], + "results": [result_item], + }, + ) + except Exception as exc: + code, message, retryable = map_calendar_exception(exc) + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + data={ + "status": "failure", + "success": 0, + "failed": 1, + "ids": [], + "results": [ + { + "action": "update", + "status": "failure", + "eventId": event_id, + "code": code, + "message": message, + } + ], + }, + error=ErrorInfo(code=code, message=message, retryable=retryable), + ) + + +async def handle_calendar_delete(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + async with AsyncSessionLocal() as session: + service = create_schedule_service(session, UUID(request.owner_id)) + event_id = str(request.args.get("event_id") or "").strip() + if not event_id: + return _fail( + request=request, + code="INVALID_ARGUMENT", + message="event_id is required", + ) + try: + await service.delete(UUID(event_id)) + return CliCommandResult( + ok=True, + command=request.command, + subcommand=request.subcommand, + data={ + "status": "success", + "success": 1, + "failed": 0, + "ids": [event_id], + "results": [ + { + "action": "delete", + "status": "success", + "eventId": event_id, + } + ], + }, + ) + except Exception as exc: + code, message, retryable = map_calendar_exception(exc) + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + data={ + "status": "failure", + "success": 0, + "failed": 1, + "ids": [], + "results": [ + { + "action": "delete", + "status": "failure", + "eventId": event_id, + "code": code, + "message": message, + } + ], + }, + error=ErrorInfo(code=code, message=message, retryable=retryable), + ) + + +async def handle_calendar_share(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + event_id = str(request.args.get("event_id", "")) + invitees = request.args.get("invitees") + if not isinstance(invitees, list): + invitees = [] + async with AsyncSessionLocal() as session: + service = create_schedule_service(session, UUID(request.owner_id)) + target_uuid = UUID(event_id) + + invited: list[str] = [] + result_items: list[dict[str, str]] = [] + + for inv in invitees: + raw_phone = inv.get("phone", "").strip() + normalized_phone = _normalize_phone(raw_phone) + if not normalized_phone: + result_items.append( + { + "phone": raw_phone, + "status": "failure", + "code": "INVALID_ARGUMENT", + "message": "invalid phone", + } + ) + continue + permission = { + "permission_view": inv.get("permission_view", True), + "permission_edit": inv.get("permission_edit", False), + "permission_invite": inv.get("permission_invite", False), + } + try: + await service.share( + target_uuid, + ScheduleItemShareRequest(phone=normalized_phone, **permission), + ) + invited.append(normalized_phone) + result_items.append({"phone": normalized_phone, "status": "success"}) + except Exception as exc: + code, message, _ = map_calendar_exception(exc) + result_items.append( + { + "phone": normalized_phone, + "status": "failure", + "code": code, + "message": message, + } + ) + + failure_count = len([r for r in result_items if r["status"] == "failure"]) + success_count = len(invited) + status = _batch_status(success_count, failure_count) + return CliCommandResult( + ok=status != "failure", + command=request.command, + subcommand=request.subcommand, + data={ + "status": status, + "success": success_count, + "failed": failure_count, + "results": result_items, + }, + ) + + +async def _create_event(service: Any, args: dict[str, Any]) -> dict[str, Any]: + start_at = args.get("start_at") + if not isinstance(start_at, str) or not start_at.strip(): + raise ValueError("create requires start_at") + event_timezone = args.get("event_timezone") + if not isinstance(event_timezone, str) or not event_timezone.strip(): + raise ValueError("create requires event_timezone") + parsed_start = parse_iso_datetime(start_at) + if parsed_start is None: + raise ValueError("invalid start_at") + + parsed_end = None + end_at = args.get("end_at") + if isinstance(end_at, str) and end_at.strip(): + parsed_end = parse_iso_datetime(end_at) + if parsed_end is None: + raise ValueError("invalid end_at") + + created = await service.create_agent_generated( + ScheduleItemCreateRequest( + title=str(args.get("title") or "new event").strip(), + description=(str(args.get("description") or "").strip() or None), + start_at=parsed_start, + end_at=parsed_end, + timezone=event_timezone.strip(), + metadata=build_schedule_metadata( + args.get("location"), + args.get("color"), + args.get("reminder_minutes"), + ), + ) + ) + return {"action": "create", "status": "success", "eventId": str(created.id)} + + +async def _update_event(service: Any, args: dict[str, Any]) -> dict[str, Any]: + event_id = args.get("event_id") + if not isinstance(event_id, str) or not event_id.strip(): + raise ValueError("update requires event_id") + + update_data: dict[str, Any] = {} + if "title" in args: + update_data["title"] = str(args.get("title") or "").strip() + if "description" in args: + update_data["description"] = str(args.get("description") or "").strip() + if "start_at" in args: + start_value = args.get("start_at") + if not isinstance(start_value, str) or not start_value.strip(): + raise ValueError("start_at must be non-empty string") + parsed_start = parse_iso_datetime(start_value) + if parsed_start is None: + raise ValueError("invalid start_at") + update_data["start_at"] = parsed_start + if "end_at" in args: + end_value = args.get("end_at") + if end_value in (None, ""): + update_data["end_at"] = None + elif isinstance(end_value, str): + parsed_end = parse_iso_datetime(end_value) + if parsed_end is None: + raise ValueError("invalid end_at") + update_data["end_at"] = parsed_end + else: + raise ValueError("end_at must be string or null") + if "event_timezone" in args: + timezone_value = args.get("event_timezone") + if not isinstance(timezone_value, str) or not timezone_value.strip(): + raise ValueError("event_timezone must be non-empty string") + update_data["timezone"] = timezone_value.strip() + if "status" in args: + update_data["status"] = ScheduleItemStatus(str(args.get("status"))) + + if any(key in args for key in ("location", "color", "reminder_minutes")): + existing = await service.get_by_id(UUID(event_id)) + update_data["metadata"] = merge_schedule_metadata_for_update( + existing_metadata=existing.metadata, + location=args.get("location"), + color=args.get("color"), + reminder_minutes=args.get("reminder_minutes"), + ) + + if not update_data: + raise ValueError("update requires at least one mutable field") + + changed_fields = sorted(update_data.keys()) + updated = await service.update( + UUID(event_id), + ScheduleItemUpdateRequest.model_validate(update_data), + ) + return { + "action": "update", + "status": "success", + "eventId": str(updated.id), + "changedFields": changed_fields, + } + + +def _normalize_phone(raw: str) -> str: + phone = raw + for sep in (" ", "-", "(", ")"): + phone = phone.replace(sep, "") + if phone.startswith("0086"): + phone = f"+86{phone[4:]}" + elif phone.startswith("86") and phone[2:].isdigit(): + phone = f"+{phone}" + elif phone.startswith("1") and phone.isdigit(): + phone = f"+86{phone}" + if ( + len(phone) != 14 + or not phone.startswith("+861") + or not phone[1:].isdigit() + or phone[4] not in "3456789" + ): + return "" + return phone + + +def _batch_status(success: int, failed: int) -> str: + if failed == 0: + return "success" + if success == 0: + return "failure" + return "partial" + + +def _resolve_read_range( + request: CliCommand, +) -> tuple[datetime | None, datetime | None, str | None]: + start_at = str(request.args.get("start_at", "")).strip() + end_at = str(request.args.get("end_at", "")).strip() + if start_at and end_at: + try: + return parse_iso_datetime(start_at), parse_iso_datetime(end_at), None + except ValueError as exc: + return None, None, str(exc) + + raw_date = str(request.args.get("date", "")).strip() + if not raw_date: + return None, None, None + + timezone_name = ( + str(request.args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai" + ) + try: + zone = ZoneInfo(timezone_name) + except Exception: + return None, None, "timezone is invalid" + + try: + target_date = date.fromisoformat(raw_date) + except ValueError: + return None, None, "date must be YYYY-MM-DD" + + start_local = datetime( + year=target_date.year, + month=target_date.month, + day=target_date.day, + hour=0, + minute=0, + second=0, + tzinfo=zone, + ) + end_local = start_local + timedelta(days=1) + return ( + parse_iso_datetime(start_local.isoformat()), + parse_iso_datetime(end_local.isoformat()), + None, + ) + + +def _fail(*, request: CliCommand, code: str, message: str) -> CliCommandResult: + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + error=ErrorInfo(code=code, message=message, retryable=False), + ) diff --git a/backend/src/core/agentscope/tools/cli/handler_contacts.py b/backend/src/core/agentscope/tools/cli/handler_contacts.py new file mode 100644 index 0000000..bb24243 --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/handler_contacts.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from core.agentscope.tools.cli.models import CliCommand, CliCommandResult +from sqlalchemy import or_, select + +from models.friendships import Friendship +from models.profile import Profile +from schemas.enums import FriendshipStatus +from v1.auth.gateway import SupabaseAuthGateway +from v1.users.contact_resolver import resolve_contacts_by_user_ids + + +async def handle_contacts_read(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + async with AsyncSessionLocal() as session: + contacts = await _list_friend_contacts(session=session, owner_id=UUID(request.owner_id)) + return CliCommandResult( + ok=True, + command=request.command, + subcommand=request.subcommand, + data={ + "friends_count": len(contacts), + "friends": contacts, + }, + ) + + +async def _list_friend_contacts( + *, + session: Any, + owner_id: UUID, +) -> list[dict[str, str]]: + friendships_stmt = ( + select(Friendship) + .where( + or_( + Friendship.user_low_id == owner_id, + Friendship.user_high_id == owner_id, + ) + ) + .where(Friendship.status == FriendshipStatus.ACCEPTED) + .where(Friendship.deleted_at.is_(None)) + ) + friendships = (await session.execute(friendships_stmt)).scalars().all() + friend_ids: list[UUID] = [] + for friendship in friendships: + friend_id = ( + friendship.user_high_id + if friendship.user_low_id == owner_id + else friendship.user_low_id + ) + friend_ids.append(friend_id) + + if not friend_ids: + return [] + + profiles_stmt = ( + select(Profile) + .where(Profile.id.in_(friend_ids)) + .where(Profile.deleted_at.is_(None)) + ) + profiles = (await session.execute(profiles_stmt)).scalars().all() + profiles_by_id = {profile.id: profile for profile in profiles} + auth_gateway = SupabaseAuthGateway() + resolved_contacts = await resolve_contacts_by_user_ids( + user_ids=friend_ids, + profiles_by_id=profiles_by_id, + auth_gateway=auth_gateway, + ) + + contacts: list[dict[str, str]] = [] + for friend_id in friend_ids: + contact = resolved_contacts.get(friend_id) + if contact is None: + continue + phone = contact.phone + if not phone: + continue + contacts.append( + { + "userId": str(friend_id), + "username": str(contact.username or ""), + "phone": phone, + } + ) + + contacts.sort(key=lambda item: (item["username"], item["phone"])) + return contacts diff --git a/backend/src/core/agentscope/tools/cli/handler_memory.py b/backend/src/core/agentscope/tools/cli/handler_memory.py new file mode 100644 index 0000000..b3484ff --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/handler_memory.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, cast +from uuid import UUID + +from core.agentscope.tools.cli.models import CliCommand, CliCommandResult +from core.agentscope.tools.utils.memory_domain import ( + create_memories_service, + map_memory_exception, +) +from schemas.agent.runtime_models import ErrorInfo +from schemas.enums import MemoryType +from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent + + +async def handle_memory_update(request: CliCommand) -> CliCommandResult: + from core.db.session import AsyncSessionLocal + + operations = request.args.get("operations") + if not isinstance(operations, list) or not operations: + return _invalid_argument( + request=request, + message="operations must be a non-empty list", + ) + + async with AsyncSessionLocal() as session: + service = create_memories_service(session=session, owner_id=UUID(request.owner_id)) + success_count = 0 + failed_count = 0 + updated_types: list[str] = [] + forgotten_total = 0 + failed_ops: list[dict[str, Any]] = [] + result_items: list[dict[str, Any]] = [] + + for idx, op in enumerate(operations): + if not isinstance(op, dict): + failed_count += 1 + failed_ops.append( + { + "code": "INVALID_ARGUMENT", + "message": "operation item must be object", + "retryable": False, + } + ) + result_items.append( + { + "idx": idx, + "memoryType": "unknown", + "action": "invalid", + "status": "failure", + "code": "INVALID_ARGUMENT", + } + ) + continue + + action = str(op.get("action") or "").strip().lower() + if action not in {"update", "delete"}: + failed_count += 1 + failed_ops.append( + { + "code": "INVALID_ARGUMENT", + "message": "action must be update or delete", + "retryable": False, + } + ) + result_items.append( + { + "idx": idx, + "memoryType": str(op.get("memory_type") or "unknown"), + "action": action or "invalid", + "status": "failure", + "code": "INVALID_ARGUMENT", + } + ) + continue + + memory_type = MemoryType(str(op.get("memory_type") or "user")) + try: + if action == "update": + result = await _apply_update_operation( + service=service, + memory_type=memory_type, + op=op, + ) + else: + result = await _apply_delete_operation( + service=service, + memory_type=memory_type, + op=op, + ) + + success_count += 1 + updated_types.append(memory_type.value) + forgotten_total += int(result.get("forgotten") or 0) + result_items.append( + { + "idx": idx, + "memoryType": memory_type.value, + "action": action, + "status": "success", + **result, + } + ) + except Exception as exc: + failed_count += 1 + code, message, retryable = map_memory_exception(exc) + failed_ops.append( + { + "memory_type": memory_type.value, + "code": code, + "message": message, + "retryable": retryable, + } + ) + result_items.append( + { + "idx": idx, + "memoryType": memory_type.value, + "action": action, + "status": "failure", + "code": code, + } + ) + + status = _batch_status(success_count, failed_count) + error_info = None + if failed_ops: + first = failed_ops[0] + error_info = ErrorInfo( + code=str(first.get("code") or "MEMORY_BATCH_FAILED"), + message=str(first.get("message") or "memory batch update failed"), + retryable=bool(first.get("retryable")), + ) + + return CliCommandResult( + ok=status != "failure", + command=request.command, + subcommand=request.subcommand, + data={ + "status": status, + "success": success_count, + "failed": failed_count, + "updated_types": sorted(set(updated_types)), + "forgotten": forgotten_total, + "results": result_items, + }, + error=error_info, + ) + + +async def _apply_update_operation( + *, + service: Any, + memory_type: MemoryType, + op: dict[str, Any], +) -> dict[str, Any]: + existing = await service.get_memory_model(memory_type=memory_type) + if memory_type == MemoryType.USER: + content_data = op.get("user_content") + if not isinstance(content_data, dict): + raise ValueError("update action for user memory requires user_content") + base = ( + UserMemoryContent.model_validate(existing.content) + if existing + else UserMemoryContent() + ) + patch = UserMemoryContent.model_validate(content_data) + merged = _deep_merge_dict( + base.model_dump(), + patch.model_dump(exclude_unset=True), + ) + validated = UserMemoryContent.model_validate(merged) + updated = await service.update_user_memory(content=validated) + else: + content_data = op.get("work_content") + if not isinstance(content_data, dict): + raise ValueError("update action for work memory requires work_content") + base = ( + WorkProfileContent.model_validate(existing.content) + if existing + else WorkProfileContent() + ) + patch = WorkProfileContent.model_validate(content_data) + merged = _deep_merge_dict( + base.model_dump(), + patch.model_dump(exclude_unset=True), + ) + validated = WorkProfileContent.model_validate(merged) + updated = await service.update_work_memory(content=validated) + + memory_id = str( + getattr(updated, "id", "") + or (getattr(existing, "id", "") if existing else "") + or "" + ) + return {"memoryId": memory_id, "forgotten": 0} + + +async def _apply_delete_operation( + *, + service: Any, + memory_type: MemoryType, + op: dict[str, Any], +) -> dict[str, Any]: + forget_paths_raw = op.get("forget_paths") + if not isinstance(forget_paths_raw, list) or not forget_paths_raw: + raise ValueError("delete action requires non-empty forget_paths") + forget_paths = [ + str(path).strip() for path in forget_paths_raw if str(path).strip() + ] + if not forget_paths: + raise ValueError("delete action requires non-empty forget_paths") + + existing = await service.get_memory_model(memory_type=memory_type) + if existing is None: + return {"memoryId": "", "forgotten": 0} + + if memory_type == MemoryType.USER: + base = UserMemoryContent.model_validate(existing.content) + updated_dict, removed = _remove_content_paths(base.model_dump(), forget_paths) + validated = UserMemoryContent.model_validate(updated_dict) + await service.update_user_memory(content=validated) + else: + base = WorkProfileContent.model_validate(existing.content) + updated_dict, removed = _remove_content_paths(base.model_dump(), forget_paths) + validated = WorkProfileContent.model_validate(updated_dict) + await service.update_work_memory(content=validated) + + return { + "memoryId": str(getattr(existing, "id", "") or ""), + "forgotten": len(removed), + } + + +def _invalid_argument(*, request: CliCommand, message: str) -> CliCommandResult: + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + error=ErrorInfo(code="INVALID_ARGUMENT", message=message, retryable=False), + ) + + +def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + merged = deepcopy(base) + for key, value in patch.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge_dict(cast(dict[str, Any], merged[key]), value) + else: + merged[key] = value + return merged + + +def _remove_content_paths( + base_payload: dict[str, Any], + paths: list[str], +) -> tuple[dict[str, Any], list[str]]: + result = deepcopy(base_payload) + removed: list[str] = [] + for raw_path in paths: + path = raw_path.strip() + if not path: + continue + keys = [part for part in path.split(".") if part] + if not keys: + continue + if _delete_nested_path(result, keys): + removed.append(path) + return result, removed + + +def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool: + current: dict[str, Any] = payload + for key in keys[:-1]: + next_value = current.get(key) + if not isinstance(next_value, dict): + return False + current = next_value + leaf = keys[-1] + if leaf in current: + del current[leaf] + return True + return False + + +def _batch_status(success: int, failed: int) -> str: + if failed == 0: + return "success" + if success == 0: + return "failure" + return "partial" diff --git a/backend/src/core/agentscope/tools/cli/handlers.py b/backend/src/core/agentscope/tools/cli/handlers.py new file mode 100644 index 0000000..7abbf7b --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/handlers.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from core.agentscope.tools.cli.handler_calendar import ( + handle_calendar_create, + handle_calendar_delete, + handle_calendar_read, + handle_calendar_share, + handle_calendar_update, +) +from core.agentscope.tools.cli.handler_contacts import handle_contacts_read +from core.agentscope.tools.cli.handler_memory import handle_memory_update +from core.agentscope.tools.cli.router import CommandRouter + + +def build_router() -> CommandRouter: + router = CommandRouter() + router.register(command="calendar", subcommand="create", handler=handle_calendar_create) + router.register(command="calendar", subcommand="read", handler=handle_calendar_read) + router.register(command="calendar", subcommand="update", handler=handle_calendar_update) + router.register(command="calendar", subcommand="delete", handler=handle_calendar_delete) + router.register(command="calendar", subcommand="share", handler=handle_calendar_share) + router.register(command="contacts", subcommand="read", handler=handle_contacts_read) + router.register(command="memory", subcommand="update", handler=handle_memory_update) + return router diff --git a/backend/src/core/agentscope/tools/cli/models.py b/backend/src/core/agentscope/tools/cli/models.py new file mode 100644 index 0000000..e78f845 --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/models.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from schemas.agent.runtime_models import ErrorInfo + + +class CliCommand(BaseModel): + model_config = ConfigDict(extra="forbid") + + command: str + subcommand: str + args: dict[str, Any] = Field(default_factory=dict) + owner_id: str + + +class CliCommandResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + ok: bool + command: str + subcommand: str + data: Any = None + error: ErrorInfo | None = None diff --git a/backend/src/core/agentscope/tools/cli/router.py b/backend/src/core/agentscope/tools/cli/router.py new file mode 100644 index 0000000..35a1274 --- /dev/null +++ b/backend/src/core/agentscope/tools/cli/router.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import sys +from typing import Any, Awaitable, Callable + +from core.agentscope.tools.cli.models import CliCommand, CliCommandResult +from core.logging import get_logger +from schemas.agent.runtime_models import ErrorInfo + +logger = get_logger("core.agentscope.tools.cli.router") + +CliHandler = Callable[[CliCommand], Awaitable[CliCommandResult]] + + +class CommandRouter: + def __init__(self) -> None: + self._handlers: dict[tuple[str, str], CliHandler] = {} + + def register(self, *, command: str, subcommand: str, handler: CliHandler) -> None: + key = (command, subcommand) + if key in self._handlers: + raise ValueError(f"command already registered: {command} {subcommand}") + self._handlers[key] = handler + + @property + def commands(self) -> set[str]: + return {command for command, _ in self._handlers.keys()} + + @property + def command_pairs(self) -> set[tuple[str, str]]: + return set(self._handlers.keys()) + + async def dispatch(self, request: CliCommand) -> CliCommandResult: + handler = self._handlers.get((request.command, request.subcommand)) + if handler is None: + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + error=ErrorInfo( + code="UNKNOWN_COMMAND", + message=f"unknown command: {request.command} {request.subcommand}", + retryable=False, + ), + ) + try: + return await handler(request) + except Exception as exc: + logger.error( + "CLI handler failed", + command=request.command, + subcommand=request.subcommand, + error=str(exc), + ) + return CliCommandResult( + ok=False, + command=request.command, + subcommand=request.subcommand, + error=ErrorInfo( + code="HANDLER_ERROR", + message=str(exc), + retryable=False, + ), + ) + + +async def cli_main(argv: list[str] | None = None) -> None: + from core.agentscope.tools.cli.handlers import build_router + + router = build_router() + if argv is None: + argv = sys.argv[1:] + if len(argv) < 2: + _write_output( + CliCommandResult( + ok=False, + command=argv[0] if argv else "", + subcommand=argv[1] if len(argv) > 1 else "", + error=ErrorInfo( + code="MISSING_COMMAND", + message="command and subcommand are required", + retryable=False, + ), + ) + ) + sys.exit(1) + + args: dict[str, Any] = {} + if len(argv) > 2: + try: + args = json.loads(argv[2]) + except (json.JSONDecodeError, TypeError): + _write_output( + CliCommandResult( + ok=False, + command=argv[0], + subcommand=argv[1], + error=ErrorInfo( + code="INVALID_ARGS", + message="args must be valid JSON", + retryable=False, + ), + ) + ) + sys.exit(1) + request = CliCommand(command=argv[0], subcommand=argv[1], args=args, owner_id=str(args.get("owner_id", ""))) + result = await router.dispatch(request) + _write_output(result) + if not result.ok: + sys.exit(1) + + +def _write_output(payload: CliCommandResult) -> None: + sys.stdout.write(json.dumps(payload.model_dump(mode="json", exclude_none=True), ensure_ascii=False, separators=(",", ":"))) + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/backend/src/core/agentscope/tools/custom/__init__.py b/backend/src/core/agentscope/tools/custom/__init__.py deleted file mode 100644 index ffb233e..0000000 --- a/backend/src/core/agentscope/tools/custom/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from core.agentscope.tools.custom.calendar import ( - calendar_share, - calendar_read, - calendar_write, -) -from core.agentscope.tools.custom.user_lookup import ( - user_lookup, -) -from core.agentscope.tools.custom.memory import ( - memory_forget, - memory_write, -) - -__all__ = [ - "calendar_read", - "calendar_write", - "calendar_share", - "user_lookup", - "memory_write", - "memory_forget", -] diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py deleted file mode 100644 index 09b29b8..0000000 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ /dev/null @@ -1,691 +0,0 @@ -import json -from typing import Annotated, Any, Literal, cast -from uuid import UUID - -from agentscope.tool import ToolResponse -from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy.ext.asyncio import AsyncSession - -from core.agentscope.tools.utils.calendar_domain import ( - build_schedule_metadata, - create_schedule_service, - map_calendar_exception, - merge_schedule_metadata_for_update, - parse_iso_datetime, - schedule_event_to_dict, -) -from core.agentscope.tools.utils.calendar_ui import ( - calendar_error_output, - dump_tool_output, -) -from core.agentscope.tools.tool_call_context import get_current_tool_call_id -from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus -from v1.schedule_items.schemas import ( - ScheduleItemCreateRequest, - ScheduleItemListRequest, - ScheduleItemShareRequest, - ScheduleItemStatus, - ScheduleItemUpdateRequest, -) - - -class CalendarShareInvitee(BaseModel): - model_config = ConfigDict(extra="forbid") - - phone: str = Field( - alias="phone", - description=( - "Target invitee phone. Accepts +8613xxxxxxxxx / 8613xxxxxxxxx " - "/ 13xxxxxxxxx and normalizes to E.164 (+86...)." - ), - ) - permission_view: bool = Field( - default=True, - alias="permissionView", - description="Whether the invitee can view the event.", - ) - permission_edit: bool = Field( - default=False, - alias="permissionEdit", - description="Whether the invitee can edit the event.", - ) - permission_invite: bool = Field( - default=False, - alias="permissionInvite", - description="Whether the invitee can invite other users.", - ) - - -class CalendarWriteOperation(BaseModel): - model_config = ConfigDict(extra="forbid") - - action: Literal["create", "update", "delete"] = Field( - description="Action type for this operation item." - ) - event_id: str | None = Field( - default=None, - description="Event id required for update/delete.", - ) - title: str | None = Field(default=None, description="Event title.") - description: str | None = Field(default=None, description="Event description.") - start_at: str | None = Field( - default=None, - description="Start time in ISO 8601 with timezone offset.", - ) - end_at: str | None = Field( - default=None, - description="End time in ISO 8601 with timezone offset.", - ) - event_timezone: str | None = Field( - default=None, - description="IANA timezone for the event.", - ) - location: str | None = Field(default=None, description="Event location.") - color: str | None = Field(default=None, description="Event color.") - reminder_minutes: int | None = Field( - default=5, - ge=0, - le=10080, - description="Reminder minutes before event start. Defaults to 5 minutes if not specified.", - ) - status: Literal["active", "archived"] | None = Field( - default=None, - description="Optional status for update action.", - ) - - -class CalendarWriteBatchArgs(BaseModel): - model_config = ConfigDict(extra="forbid") - - operations: list[CalendarWriteOperation] = Field(min_length=1, max_length=20) - - -class CalendarShareArgs(BaseModel): - model_config = ConfigDict(extra="forbid") - - event_id: str - invitees: list[CalendarShareInvitee] = Field(min_length=1) - - -def _validate_runtime_context( - *, - tool_name: str, - tool_call_args: dict[str, Any], - session: Any, - owner_id: Any, -) -> ToolResponse | None: - if session is None or owner_id is None: - return calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code="MISSING_RUNTIME_ARGS", - message="日历工具缺少运行时参数", - retryable=False, - ) - return None - - -async def calendar_read( - start_at: Annotated[ - str, - Field( - description="Start of date range in ISO8601 with timezone, e.g. 2026-03-30T00:00:00+08:00." - ), - ], - end_at: Annotated[ - str, - Field( - description="End of date range in ISO8601 with timezone, e.g. 2026-03-30T23:59:59+08:00." - ), - ], - session: Any = None, - owner_id: Any = None, -) -> ToolResponse: - """Read calendar events within a date range. - - Returns subscribed calendar events (owned or shared) with permission info. - - Status: active=actionable, archived=past/expired. - - Permission flags: is_owner, can_view, can_edit, can_invite, can_delete. - - Args: - start_at: Start of date range (required). - end_at: End of date range (required). - - Returns: - ToolResponse with JSON result: - { - "total": int, - "items": [{ - "id": "uuid", - "owner_id": "uuid", - "title": "string", - "description": "string|null", - "start_at": "ISO8601 datetime", - "end_at": "ISO8601 datetime|null", - "timezone": "IANA timezone", - "status": "active|archived", - "source_type": "manual|imported|agent_generated", - "permission": {"can_view", "can_edit", "can_invite", "can_delete", "is_owner"}, - "is_owner": boolean, - "metadata": {color, location, reminder_minutes}|null, - "subscribers": [{user_id, username, phone, permission, status}], - "created_at": "ISO8601 datetime", - "updated_at": "ISO8601 datetime" - }] - } - """ - tool_name = "calendar_read" - tool_call_args: dict[str, Any] = {"start_at": start_at, "end_at": end_at} - - runtime_error = _validate_runtime_context( - tool_name=tool_name, - tool_call_args=tool_call_args, - session=session, - owner_id=owner_id, - ) - if runtime_error is not None: - return runtime_error - - try: - parsed_start = parse_iso_datetime(start_at) - parsed_end = parse_iso_datetime(end_at) - if parsed_start is None or parsed_end is None: - raise ValueError("start_at 和 end_at 都是必填项") - if parsed_start >= parsed_end: - raise ValueError("start_at 必须早于 end_at") - - service = create_schedule_service( - cast(AsyncSession, session), cast(UUID, owner_id) - ) - request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end) - items = await service.list_by_date_range(request) - event_items = [schedule_event_to_dict(item) for item in items] - result = json.dumps( - {"total": len(event_items), "items": event_items}, - ensure_ascii=False, - ) - return dump_tool_output( - ToolAgentOutput( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - tool_call_args=tool_call_args, - status=ToolStatus.SUCCESS, - result=result, - ) - ) - except Exception as exc: - code, message, retryable = map_calendar_exception(exc) - return calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code=code, - message=message, - retryable=retryable, - ) - - -async def calendar_write( - operations: Annotated[ - list[CalendarWriteOperation], - Field( - description=( - "Batch operation objects. Each item includes action and its fields. " - "Use create/update/delete in a single call." - ), - min_length=1, - max_length=20, - ), - ], - session: Any = None, - owner_id: Any = None, -) -> ToolResponse: - """Batch create/update/delete calendar events using operation objects. - - Args: - operations: Batch operation objects. - - create requires start_at and event_timezone. - - update/delete requires event_id. - - datetime fields must include timezone offset. - - Returns: - ToolResponse with serialized ToolAgentOutput payload. - """ - tool_name = "calendar_write" - try: - parsed_batch = CalendarWriteBatchArgs.model_validate({"operations": operations}) - except Exception as exc: # noqa: BLE001 - code, message, retryable = map_calendar_exception(exc) - return calendar_error_output( - tool_name=tool_name, - tool_call_args={"operations": operations}, - code=code, - message=message, - retryable=retryable, - ) - - tool_call_args = { - "operations": [ - operation.model_dump(mode="json", exclude_none=True) - for operation in parsed_batch.operations - ] - } - runtime_error = _validate_runtime_context( - tool_name=tool_name, - tool_call_args=tool_call_args, - session=session, - owner_id=owner_id, - ) - if runtime_error is not None: - return runtime_error - - try: - service = create_schedule_service( - cast(AsyncSession, session), cast(UUID, owner_id) - ) - - success_count = 0 - failed_count = 0 - success_event_ids: list[str] = [] - result_items: list[dict[str, Any]] = [] - - for operation in parsed_batch.operations: - event_id = operation.event_id - title = operation.title - description = operation.description - start_at = operation.start_at - end_at = operation.end_at - event_timezone = operation.event_timezone - location = operation.location - color = operation.color - reminder_minutes = operation.reminder_minutes - status = operation.status - - try: - if operation.action == "create": - if start_at is None or not start_at.strip(): - raise ValueError( - "创建日程需要提供 start_at,且必须包含时区偏移" - ) - if event_timezone is None or not event_timezone.strip(): - raise ValueError("创建日程需要提供 event_timezone") - parsed_start = parse_iso_datetime(start_at) - if parsed_start is None: - raise ValueError( - "创建日程需要提供 start_at,且必须包含时区偏移" - ) - parsed_end = parse_iso_datetime(end_at) if end_at else None - created = await service.create_agent_generated( - ScheduleItemCreateRequest( - title=title.strip() - if title and title.strip() - else "新的日程", - description=description.strip() - if description and description.strip() - else None, - start_at=parsed_start, - end_at=parsed_end, - timezone=event_timezone.strip(), - metadata=build_schedule_metadata( - location, - color, - cast(int | None, reminder_minutes), - ), - ) - ) - success_count += 1 - result_items.append( - { - "status": "success", - "eventId": str(created.id), - } - ) - success_event_ids.append(str(created.id)) - continue - - if operation.action == "update": - if event_id is None or not event_id.strip(): - raise ValueError("更新日程需要提供 event_id") - parsed_event_id = UUID(event_id) - update_data: dict[str, Any] = {} - if title is not None: - update_data["title"] = title.strip() - if description is not None: - update_data["description"] = description.strip() - if start_at: - update_data["start_at"] = parse_iso_datetime(start_at) - if end_at: - update_data["end_at"] = parse_iso_datetime(end_at) - if event_timezone is not None: - timezone_value = event_timezone.strip() - if not timezone_value: - raise ValueError("event_timezone 不能为空") - update_data["timezone"] = timezone_value - if status: - update_data["status"] = ScheduleItemStatus(status) - if location or color or reminder_minutes is not None: - existing = await service.get_by_id(parsed_event_id) - update_data["metadata"] = merge_schedule_metadata_for_update( - existing_metadata=existing.metadata, - location=cast(str | None, location), - color=cast(str | None, color), - reminder_minutes=cast(int | None, reminder_minutes), - ) - changed_fields = sorted(update_data.keys()) - updated = await service.update( - parsed_event_id, - ScheduleItemUpdateRequest.model_validate(update_data), - ) - success_count += 1 - result_items.append( - { - "status": "success", - "eventId": str(updated.id), - "changedFields": changed_fields, - } - ) - success_event_ids.append(str(updated.id)) - continue - - if operation.action == "delete": - if event_id is None or not event_id.strip(): - raise ValueError("删除日程需要提供 event_id") - await service.delete(UUID(event_id)) - success_count += 1 - result_items.append( - { - "status": "success", - "eventId": event_id, - } - ) - success_event_ids.append(event_id) - continue - except Exception as exc: - code, message, _ = map_calendar_exception(exc) - failed_count += 1 - result_items.append( - { - "status": "failure", - "eventId": event_id, - "code": code, - "message": message, - } - ) - - if failed_count == 0: - final_status = ToolStatus.SUCCESS - summary = ( - f"status=success success={success_count} failed={failed_count} " - f"ids=[{','.join(success_event_ids)}]" - ) - elif success_count == 0: - final_status = ToolStatus.FAILURE - summary = f"status=failure success={success_count} failed={failed_count}" - else: - final_status = ToolStatus.PARTIAL - summary = ( - f"status=partial success={success_count} failed={failed_count} " - f"ids=[{','.join(success_event_ids)}]" - ) - compact_items = ",".join( - [ - "{" - f"status={item.get('status')}," - f"eventId={item.get('eventId')},code={item.get('code')}," - f"changedFields={item.get('changedFields')}" - "}" - for item in result_items - ] - ) - if compact_items: - summary = f"{summary} items=[{compact_items}]" - - error_info: ErrorInfo | None = None - if final_status == ToolStatus.FAILURE: - first_failure = next( - ( - item - for item in result_items - if isinstance(item, dict) and item.get("status") == "failure" - ), - None, - ) - error_info = ErrorInfo( - code=str( - first_failure.get("code") if first_failure else "BATCH_FAILED" - ), - message=str( - first_failure.get("message") - if first_failure and first_failure.get("message") - else summary - ), - retryable=False, - details={"results": result_items}, - ) - summary = ( - f"{summary} first_error_code={error_info.code} " - f"first_error_message={error_info.message}" - ) - - return dump_tool_output( - ToolAgentOutput( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - tool_call_args=tool_call_args, - status=final_status, - result=summary, - error=error_info, - ) - ) - - except Exception as exc: - code, message, retryable = map_calendar_exception(exc) - return calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code=code, - message=message, - retryable=retryable, - ) - - -async def calendar_share( - event_id: Annotated[ - str, - Field(description="Target event ID (UUID string)."), - ], - invitees: Annotated[ - list[CalendarShareInvitee], - Field( - description=( - "Invitee list with phone and per-user permissions. " - "Prefer composing with user_lookup tool to pick a friend phone first." - ), - min_length=1, - ), - ], - session: Any = None, - owner_id: Any = None, -) -> ToolResponse: - """Share a calendar event with invitee phones. - - Input contract: - - invitees use `phone` (not `userId`) - - phone accepts local/86/E.164 forms and is normalized before share - - Orchestration contract: - - prefer `user_lookup` first to get friend candidates - - choose matched friend phone(s) - - call `calendar_share` - - Output contract: - - status can be success / partial / failure - - result includes per-item outcomes in `items=[{phone,status,code}]` - - first failure is exposed in `error` when any item fails - - Args: - event_id: Target event id as UUID string. - invitees: Invitee list with phone and per-user permissions. - - Returns: - ToolResponse with serialized ToolAgentOutput payload. - """ - tool_name = "calendar_share" - try: - parsed_args = CalendarShareArgs.model_validate( - {"event_id": event_id, "invitees": invitees} - ) - except Exception as exc: # noqa: BLE001 - code, message, retryable = map_calendar_exception(exc) - return calendar_error_output( - tool_name=tool_name, - tool_call_args={"event_id": event_id, "invitees": invitees}, - code=code, - message=message, - retryable=retryable, - ) - - tool_call_args = { - "event_id": parsed_args.event_id, - "invitees": [ - invitee.model_dump(mode="json", by_alias=True) - for invitee in parsed_args.invitees - ], - } - runtime_error = _validate_runtime_context( - tool_name=tool_name, - tool_call_args=tool_call_args, - session=session, - owner_id=owner_id, - ) - if runtime_error is not None: - return runtime_error - - try: - service = create_schedule_service( - cast(AsyncSession, session), cast(UUID, owner_id) - ) - target_uuid = UUID(parsed_args.event_id) - - invited: list[str] = [] - result_items: list[dict[str, str]] = [] - for invitee in parsed_args.invitees: - raw_phone = invitee.phone.strip() - normalized_phone = raw_phone - for separator in (" ", "-", "(", ")"): - normalized_phone = normalized_phone.replace(separator, "") - if normalized_phone.startswith("0086"): - normalized_phone = f"+86{normalized_phone[4:]}" - elif normalized_phone.startswith("86") and normalized_phone[2:].isdigit(): - normalized_phone = f"+{normalized_phone}" - elif normalized_phone.startswith("1") and normalized_phone.isdigit(): - normalized_phone = f"+86{normalized_phone}" - if ( - len(normalized_phone) != 14 - or not normalized_phone.startswith("+861") - or not normalized_phone[1:].isdigit() - or normalized_phone[4] not in {"3", "4", "5", "6", "7", "8", "9"} - ): - result_items.append( - { - "phone": raw_phone, - "status": "failure", - "code": "INVALID_ARGUMENT", - "message": "无效手机号格式", - } - ) - continue - permission = { - "permission_view": invitee.permission_view, - "permission_edit": invitee.permission_edit, - "permission_invite": invitee.permission_invite, - } - try: - await service.share( - target_uuid, - ScheduleItemShareRequest(phone=normalized_phone, **permission), - ) - invited.append(normalized_phone) - result_items.append( - { - "phone": normalized_phone, - "status": "success", - } - ) - except Exception as exc: - code, message, _ = map_calendar_exception(exc) - result_items.append( - { - "phone": normalized_phone, - "status": "failure", - "code": code, - "message": message, - } - ) - - failure_count = len( - [item for item in result_items if item["status"] == "failure"] - ) - success_count = len(invited) - if success_count and failure_count: - final_status = ToolStatus.PARTIAL - elif success_count: - final_status = ToolStatus.SUCCESS - else: - final_status = ToolStatus.FAILURE - - compact_items = ",".join( - [ - "{" - f"phone={item.get('phone')},status={item.get('status')}," - f"code={item.get('code', '')}" - "}" - for item in result_items - ] - ) - summary = ( - f"status={final_status.value} success={success_count} " - f"failed={failure_count}" - ) - if compact_items: - summary = f"{summary} items=[{compact_items}]" - - error_info: ErrorInfo | None = None - if failure_count: - first_failure = next( - (item for item in result_items if item.get("status") == "failure"), - None, - ) - error_info = ErrorInfo( - code=str( - first_failure.get("code") if first_failure else "INTERNAL_ERROR" - ), - message=str( - first_failure.get("message") - if first_failure and first_failure.get("message") - else "日历分享失败" - ), - retryable=False, - details={"results": result_items}, - ) - - return dump_tool_output( - ToolAgentOutput( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - tool_call_args=tool_call_args, - status=final_status, - result=summary, - error=error_info, - ) - ) - except Exception as exc: - code, message, retryable = map_calendar_exception(exc) - return calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code=code, - message=message, - retryable=retryable, - ) diff --git a/backend/src/core/agentscope/tools/custom/memory.py b/backend/src/core/agentscope/tools/custom/memory.py deleted file mode 100644 index 87ba62f..0000000 --- a/backend/src/core/agentscope/tools/custom/memory.py +++ /dev/null @@ -1,504 +0,0 @@ -from copy import deepcopy -from typing import Annotated, Any, cast -from uuid import UUID - -from agentscope.tool import ToolResponse -from pydantic import BaseModel, ConfigDict, Field, model_validator -from sqlalchemy.ext.asyncio import AsyncSession - -from core.agentscope.tools.tool_call_context import get_current_tool_call_id -from core.agentscope.tools.utils.memory_domain import ( - create_memories_service, - map_memory_exception, -) -from core.agentscope.tools.utils.tool_response_builder import ( - build_error_output, - build_tool_response, -) -from schemas.enums import MemoryType -from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus -from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent - - -class MemoryWriteArgs(BaseModel): - model_config = ConfigDict(extra="forbid") - - memory_type: MemoryType = MemoryType.USER - user_content: UserMemoryContent | None = None - work_content: WorkProfileContent | None = None - - @model_validator(mode="after") - def validate_content(self) -> "MemoryWriteArgs": - if self.memory_type == MemoryType.USER: - if self.user_content is None or self.work_content is not None: - raise ValueError("memory_type=user requires user_content only") - else: - if self.work_content is None or self.user_content is not None: - raise ValueError("memory_type=work requires work_content only") - return self - - -class MemoryWriteBatchArgs(BaseModel): - model_config = ConfigDict(extra="forbid") - - operations: list[MemoryWriteArgs] = Field(min_length=1, max_length=20) - - -class MemoryForgetArgs(BaseModel): - model_config = ConfigDict(extra="forbid") - - memory_type: MemoryType = MemoryType.USER - forget_paths: list[str] = Field(min_length=1, max_length=100) - - @model_validator(mode="after") - def validate_forget_paths(self) -> "MemoryForgetArgs": - allowed_roots = ( - set(UserMemoryContent.model_fields) - if self.memory_type == MemoryType.USER - else set(WorkProfileContent.model_fields) - ) - normalized: list[str] = [] - for raw_path in self.forget_paths: - path = raw_path.strip() - if not path: - continue - parts = [part for part in path.split(".") if part] - if not parts: - continue - if len(parts) > 5: - raise ValueError("forget path depth exceeds limit") - if parts[0] not in allowed_roots: - raise ValueError("forget path root is not allowed") - normalized.append(path) - if not normalized: - raise ValueError("forget_paths cannot be empty") - self.forget_paths = normalized - return self - - -class MemoryForgetBatchArgs(BaseModel): - model_config = ConfigDict(extra="forbid") - - operations: list[MemoryForgetArgs] = Field(min_length=1, max_length=20) - - -def _memory_error_output( - *, - tool_name: str, - tool_call_args: dict[str, Any], - code: str, - message: str, - retryable: bool, -) -> ToolResponse: - output = build_error_output( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - code=code, - message=message, - retryable=retryable, - ) - output = output.model_copy(update={"tool_call_args": tool_call_args}) - return build_tool_response(output) - - -def _validate_runtime_context( - *, - tool_name: str, - tool_call_args: dict[str, Any], - session: Any, - owner_id: Any, -) -> ToolResponse | None: - if session is None or owner_id is None: - return _memory_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code="MISSING_RUNTIME_ARGS", - message="记忆工具缺少运行时参数", - retryable=False, - ) - return None - - -def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: - merged = deepcopy(base) - for key, value in patch.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = _deep_merge_dict(cast(dict[str, Any], merged[key]), value) - else: - merged[key] = value - return merged - - -def _remove_content_paths( - base_payload: dict[str, Any], - paths: list[str], -) -> tuple[dict[str, Any], list[str]]: - result = deepcopy(base_payload) - removed: list[str] = [] - for raw_path in paths: - path = raw_path.strip() - if not path: - continue - keys = [part for part in path.split(".") if part] - if not keys: - continue - if _delete_nested_path(result, keys): - removed.append(path) - return result, removed - - -def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool: - current: dict[str, Any] = payload - for key in keys[:-1]: - next_value = current.get(key) - if not isinstance(next_value, dict): - return False - current = next_value - leaf = keys[-1] - if leaf in current: - del current[leaf] - return True - return False - - -def _compact_result_items(items: list[dict[str, object]]) -> str: - return ",".join( - "{" + ",".join(f"{key}={value}" for key, value in item.items()) + "}" - for item in items - ) - - -async def memory_write( - operations: Annotated[ - list[MemoryWriteArgs], - Field( - description=( - "Batch memory write operations. Each item must include memory_type and " - "the matching content object (user_content or work_content)." - ), - min_length=1, - max_length=20, - ), - ], - session: Any = None, - owner_id: Any = None, -) -> ToolResponse: - """Merge structured facts into user/work memory. - - Args: - memory_type: Target memory domain, either ``user`` or ``work``. - user_content: Partial user-memory payload when ``memory_type='user'``. - work_content: Partial work-memory payload when ``memory_type='work'``. - - Runtime: - ``session`` and ``owner_id`` are injected by toolkit preset kwargs. - - Returns: - ToolResponse wrapping ToolAgentOutput. - - success: ``result`` contains a compact status summary. - - failure: ``error`` contains structured code/message/retryable metadata. - """ - tool_name = "memory_write" - tool_call_args: dict[str, Any] = {"operations": operations} - runtime_error = _validate_runtime_context( - tool_name=tool_name, - tool_call_args=tool_call_args, - session=session, - owner_id=owner_id, - ) - if runtime_error is not None: - return runtime_error - - try: - parsed_batch = MemoryWriteBatchArgs.model_validate(tool_call_args) - service = create_memories_service( - session=cast(AsyncSession, session), - owner_id=cast(UUID, owner_id), - ) - success_count = 0 - failed_count = 0 - updated_types: list[str] = [] - failed_operations: list[dict[str, object]] = [] - result_items: list[dict[str, object]] = [] - for idx, op in enumerate(parsed_batch.operations): - try: - existing = await service.get_memory_model(memory_type=op.memory_type) - if op.memory_type == MemoryType.USER: - base_model = ( - UserMemoryContent.model_validate(existing.content) - if existing is not None - else UserMemoryContent() - ) - patch_model = cast(UserMemoryContent, op.user_content) - merged = _deep_merge_dict( - base_model.model_dump(), - patch_model.model_dump(exclude_unset=True), - ) - validated = UserMemoryContent.model_validate(merged) - updated = await service.update_user_memory(content=validated) - else: - base_model = ( - WorkProfileContent.model_validate(existing.content) - if existing is not None - else WorkProfileContent() - ) - patch_model = cast(WorkProfileContent, op.work_content) - merged = _deep_merge_dict( - base_model.model_dump(), - patch_model.model_dump(exclude_unset=True), - ) - validated = WorkProfileContent.model_validate(merged) - updated = await service.update_work_memory(content=validated) - - success_count += 1 - updated_types.append(op.memory_type.value) - memory_id = str( - getattr(updated, "id", None) - or (getattr(existing, "id", None) if existing is not None else "") - or "" - ) - result_items.append( - { - "idx": idx, - "memoryType": op.memory_type.value, - "status": "success", - "memoryId": memory_id, - } - ) - except Exception as exc: # noqa: BLE001 - failed_count += 1 - code, message, retryable = map_memory_exception(exc) - failed_operations.append( - { - "memory_type": op.memory_type.value, - "code": code, - "message": message, - "retryable": retryable, - } - ) - result_items.append( - { - "idx": idx, - "memoryType": op.memory_type.value, - "status": "failure", - "code": code, - } - ) - - status = ( - ToolStatus.SUCCESS - if failed_count == 0 - else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL) - ) - status_text = ( - "success" - if status == ToolStatus.SUCCESS - else ("failure" if status == ToolStatus.FAILURE else "partial") - ) - - summary = ( - f"status={status_text} " - f"success={success_count} failed={failed_count} " - f"updated_types=[{','.join(updated_types)}]" - ) - compact_items = _compact_result_items(result_items) - if compact_items: - summary = f"{summary} items=[{compact_items}]" - error_info: ErrorInfo | None = None - if failed_operations: - first = failed_operations[0] - error_info = ErrorInfo( - code=str(first.get("code") or "MEMORY_BATCH_FAILED"), - message=str(first.get("message") or "memory batch write failed"), - retryable=bool(first.get("retryable") is True), - details={"failed_operations": failed_operations}, - ) - return build_tool_response( - ToolAgentOutput( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - tool_call_args=tool_call_args, - status=status, - result=summary, - error=error_info, - ) - ) - except Exception as exc: # noqa: BLE001 - code, message, retryable = map_memory_exception(exc) - return _memory_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code=code, - message=message, - retryable=retryable, - ) - - -async def memory_forget( - operations: Annotated[ - list[MemoryForgetArgs], - Field( - description=( - "Batch memory forget operations. Each item must include memory_type and " - "forget_paths." - ), - min_length=1, - max_length=20, - ), - ], - session: Any = None, - owner_id: Any = None, -) -> ToolResponse: - """Forget selected paths from user/work memory content. - - Args: - memory_type: Target memory domain, either ``user`` or ``work``. - forget_paths: Dot-path list to remove from memory content. - - Notes: - - Path root must belong to the target memory schema. - - The tool is idempotent; missing paths are skipped safely. - - Runtime: - ``session`` and ``owner_id`` are injected by toolkit preset kwargs. - - Returns: - ToolResponse wrapping ToolAgentOutput with compact execution summary. - """ - tool_name = "memory_forget" - tool_call_args: dict[str, Any] = {"operations": operations} - runtime_error = _validate_runtime_context( - tool_name=tool_name, - tool_call_args=tool_call_args, - session=session, - owner_id=owner_id, - ) - if runtime_error is not None: - return runtime_error - - try: - parsed_batch = MemoryForgetBatchArgs.model_validate(tool_call_args) - service = create_memories_service( - session=cast(AsyncSession, session), - owner_id=cast(UUID, owner_id), - ) - success_count = 0 - failed_count = 0 - forgotten_total = 0 - processed_types: list[str] = [] - failed_operations: list[dict[str, object]] = [] - result_items: list[dict[str, object]] = [] - for idx, op in enumerate(parsed_batch.operations): - try: - existing = await service.get_memory_model(memory_type=op.memory_type) - if existing is None: - success_count += 1 - processed_types.append(op.memory_type.value) - result_items.append( - { - "idx": idx, - "memoryType": op.memory_type.value, - "status": "success", - "forgotten": 0, - "memoryId": "", - } - ) - continue - - if op.memory_type == MemoryType.USER: - base_model = UserMemoryContent.model_validate(existing.content) - updated_dict, removed_paths = _remove_content_paths( - base_model.model_dump(), - op.forget_paths, - ) - validated = UserMemoryContent.model_validate(updated_dict) - await service.update_user_memory(content=validated) - else: - base_model = WorkProfileContent.model_validate(existing.content) - updated_dict, removed_paths = _remove_content_paths( - base_model.model_dump(), - op.forget_paths, - ) - validated = WorkProfileContent.model_validate(updated_dict) - await service.update_work_memory(content=validated) - - forgotten_total += len(removed_paths) - success_count += 1 - processed_types.append(op.memory_type.value) - result_items.append( - { - "idx": idx, - "memoryType": op.memory_type.value, - "status": "success", - "forgotten": len(removed_paths), - "memoryId": str(getattr(existing, "id", "") or ""), - } - ) - except Exception as exc: # noqa: BLE001 - failed_count += 1 - code, message, retryable = map_memory_exception(exc) - failed_operations.append( - { - "memory_type": op.memory_type.value, - "code": code, - "message": message, - "retryable": retryable, - } - ) - result_items.append( - { - "idx": idx, - "memoryType": op.memory_type.value, - "status": "failure", - "code": code, - } - ) - - status = ( - ToolStatus.SUCCESS - if failed_count == 0 - else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL) - ) - status_text = ( - "success" - if status == ToolStatus.SUCCESS - else ("failure" if status == ToolStatus.FAILURE else "partial") - ) - - summary = ( - f"status={status_text} " - f"success={success_count} failed={failed_count} " - f"forgotten={forgotten_total} " - f"processed_types=[{','.join(processed_types)}]" - ) - compact_items = _compact_result_items(result_items) - if compact_items: - summary = f"{summary} items=[{compact_items}]" - error_info: ErrorInfo | None = None - if failed_operations: - first = failed_operations[0] - error_info = ErrorInfo( - code=str(first.get("code") or "MEMORY_BATCH_FAILED"), - message=str(first.get("message") or "memory batch forget failed"), - retryable=bool(first.get("retryable") is True), - details={"failed_operations": failed_operations}, - ) - return build_tool_response( - ToolAgentOutput( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - tool_call_args=tool_call_args, - status=status, - result=summary, - error=error_info, - ) - ) - except Exception as exc: # noqa: BLE001 - code, message, retryable = map_memory_exception(exc) - return _memory_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code=code, - message=message, - retryable=retryable, - ) diff --git a/backend/src/core/agentscope/tools/custom/user_lookup.py b/backend/src/core/agentscope/tools/custom/user_lookup.py deleted file mode 100644 index cc68c98..0000000 --- a/backend/src/core/agentscope/tools/custom/user_lookup.py +++ /dev/null @@ -1,178 +0,0 @@ -from typing import Any, cast -from uuid import UUID - -from sqlalchemy import or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from agentscope.tool import ToolResponse -from core.agentscope.tools.tool_call_context import get_current_tool_call_id -from core.agentscope.tools.utils.tool_response_builder import ( - build_error_output, - build_tool_response, -) -from models.friendships import Friendship -from models.profile import Profile -from schemas.enums import FriendshipStatus -from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus -from v1.auth.gateway import SupabaseAuthGateway -from v1.users.contact_resolver import resolve_contacts_by_user_ids - - -def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse: - return build_tool_response(output) - - -def _lookup_error_output( - *, - tool_call_args: dict[str, Any], - code: str, - message: str, - retryable: bool, -) -> ToolResponse: - output = build_error_output( - tool_name="user_lookup", - tool_call_id=get_current_tool_call_id(tool_name="user_lookup"), - code=code, - message=message, - retryable=retryable, - ) - output = output.model_copy(update={"tool_call_args": tool_call_args}) - return _dump_tool_output(output) - - -async def _list_friend_contacts( - *, - session: AsyncSession, - owner_id: UUID, -) -> list[dict[str, str]]: - """Load accepted friends and return contact tuples. - - Returns items shaped as: - - userId: friend user UUID string - - username: friend username - - phone: friend phone in E.164 format - """ - friendships_stmt = ( - select(Friendship) - .where( - or_( - Friendship.user_low_id == owner_id, - Friendship.user_high_id == owner_id, - ) - ) - .where(Friendship.status == FriendshipStatus.ACCEPTED) - .where(Friendship.deleted_at.is_(None)) - ) - friendships = (await session.execute(friendships_stmt)).scalars().all() - friend_ids: list[UUID] = [] - for friendship in friendships: - friend_id = ( - friendship.user_high_id - if friendship.user_low_id == owner_id - else friendship.user_low_id - ) - friend_ids.append(friend_id) - - if not friend_ids: - return [] - - profiles_stmt = ( - select(Profile) - .where(Profile.id.in_(friend_ids)) - .where(Profile.deleted_at.is_(None)) - ) - profiles = (await session.execute(profiles_stmt)).scalars().all() - profiles_by_id = {profile.id: profile for profile in profiles} - auth_gateway = SupabaseAuthGateway() - resolved_contacts = await resolve_contacts_by_user_ids( - user_ids=friend_ids, - profiles_by_id=profiles_by_id, - auth_gateway=auth_gateway, - ) - - contacts: list[dict[str, str]] = [] - for friend_id in friend_ids: - contact = resolved_contacts.get(friend_id) - if contact is None: - continue - phone = contact.phone - if not phone: - continue - contacts.append( - { - "userId": str(friend_id), - "username": str(contact.username or ""), - "phone": phone, - } - ) - - contacts.sort(key=lambda item: (item["username"], item["phone"])) - return contacts - - -async def user_lookup( - session: Any = None, - owner_id: Any = None, -) -> ToolResponse: - """List current user's accepted friend contacts. - - This tool is intentionally argument-free for business inputs. Runtime - context (`session`, `owner_id`) is injected by toolkit preset kwargs. - - Intended composition: - 1) call `user_lookup` to obtain friend username/phone candidates - 2) resolve target friend from user utterance - 3) call `calendar_share` with selected phone(s) - - Result format (in ToolAgentOutput.result): - - status=success - - friends_count= - - friends=[{userId=...,username=...,phone=...}, ...] - - Returns: - ToolResponse with serialized ToolAgentOutput payload. - """ - tool_call_args: dict[str, Any] = {} - - if session is None or owner_id is None: - return _lookup_error_output( - tool_call_args=tool_call_args, - code="MISSING_RUNTIME_ARGS", - message="用户查找工具缺少运行时参数", - retryable=False, - ) - - try: - contacts = await _list_friend_contacts( - session=cast(AsyncSession, session), - owner_id=cast(UUID, owner_id), - ) - compact_items = ",".join( - [ - "{" - f"userId={item.get('userId')}," - f"username={item.get('username')}," - f"phone={item.get('phone')}" - "}" - for item in contacts - ] - ) - summary = f"status=success friends_count={len(contacts)}" - if compact_items: - summary = f"{summary} friends=[{compact_items}]" - return _dump_tool_output( - ToolAgentOutput( - tool_name="user_lookup", - tool_call_id=get_current_tool_call_id(tool_name="user_lookup"), - tool_call_args=tool_call_args, - status=ToolStatus.SUCCESS, - result=summary, - ) - ) - except Exception as exc: - return _lookup_error_output( - tool_call_args=tool_call_args, - code="INTERNAL_ERROR", - message=f"好友查找失败: {str(exc)}", - retryable=True, - ) diff --git a/backend/src/core/agentscope/tools/internal/__init__.py b/backend/src/core/agentscope/tools/internal/__init__.py new file mode 100644 index 0000000..11c1c07 --- /dev/null +++ b/backend/src/core/agentscope/tools/internal/__init__.py @@ -0,0 +1,2 @@ +from core.agentscope.tools.internal.project_cli import make_project_cli_wrapper as make_project_cli_wrapper +from core.agentscope.tools.internal.view_skill_file import make_view_skill_file_wrapper as make_view_skill_file_wrapper diff --git a/backend/src/core/agentscope/tools/internal/project_cli.py b/backend/src/core/agentscope/tools/internal/project_cli.py new file mode 100644 index 0000000..aa6afd8 --- /dev/null +++ b/backend/src/core/agentscope/tools/internal/project_cli.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Any + +from agentscope.tool import ToolResponse + +from core.agentscope.tools.cli import invoke_cli_tool + +PROJECT_CLI_TOOL_NAME = "project_cli" + + +def make_project_cli_wrapper(*, allowed_commands: set[str]) -> Any: + async def _project_cli( + command: str, + subcommand: str, + args: dict[str, Any] | None = None, + ) -> ToolResponse: + tool_call_args = { + "command": command, + "subcommand": subcommand, + "args": args or {}, + } + return await invoke_cli_tool( + tool_name=PROJECT_CLI_TOOL_NAME, + tool_call_args=tool_call_args, + allowed_commands=allowed_commands, + ) + + _project_cli.__name__ = PROJECT_CLI_TOOL_NAME + _project_cli.__doc__ = """Execute CLI commands for calendar, contacts, and memory operations. + +Args: + command: The command to execute (calendar, contacts, memory). + subcommand: The subcommand for the operation (calendar: create/read/update/delete/share; contacts: read; memory: update). + args: Arguments for the command as a JSON object. + +Returns: + ToolResponse with the command result. +""" + return _project_cli diff --git a/backend/src/core/agentscope/tools/internal/view_skill_file.py b/backend/src/core/agentscope/tools/internal/view_skill_file.py new file mode 100644 index 0000000..baa4d4b --- /dev/null +++ b/backend/src/core/agentscope/tools/internal/view_skill_file.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from agentscope.message import TextBlock +from agentscope.tool import ToolResponse + +SKILLS_DIR = Path(__file__).parent.parent / "skills" +VIEW_SKILL_FILE_TOOL_NAME = "view_skill_file" + + +def make_view_skill_file_wrapper(*, enabled_skill_names: set[str]) -> Any: + skills_root = SKILLS_DIR.resolve() + + async def _view_skill_file( + file_path: str, + ranges: list[int] | None = None, + ) -> ToolResponse: + normalized = file_path.strip().replace("\\", "/") + if normalized.startswith("/"): + normalized = normalized[1:] + + parts = normalized.split("/") + if not parts: + return _error_response("INVALID_PATH", "file_path cannot be empty") + + skill_name = parts[0] + if skill_name not in enabled_skill_names: + return _error_response( + "ACCESS_DENIED", + f"skill '{skill_name}' is not enabled. Enabled skills: {sorted(enabled_skill_names)}", + ) + + target_path = skills_root / normalized + try: + target_path = target_path.resolve() + target_path.relative_to(skills_root) + except Exception: + return _error_response("ACCESS_DENIED", "access denied: path outside skills directory") + + if not target_path.exists() or not target_path.is_file(): + return _error_response("FILE_NOT_FOUND", f"file not found: {file_path}") + + try: + content = target_path.read_text(encoding="utf-8") + except Exception as exc: + return _error_response("READ_ERROR", f"failed to read file: {exc}") + + lines = content.splitlines() + if ranges and len(ranges) >= 2: + start = max(1, ranges[0]) + end = min(len(lines), ranges[1]) + lines = lines[start - 1 : end] + + text = "\n".join(lines) + + return ToolResponse( + content=[ + TextBlock( + type="text", + text=text, + ) + ] + ) + + _view_skill_file.__name__ = VIEW_SKILL_FILE_TOOL_NAME + _view_skill_file.__doc__ = """Read skill instruction files within enabled skill directories. + +This tool provides progressive disclosure of skill instructions. You should call this tool +to read the SKILL.md file of a skill before using project_cli for that skill's commands. + +Args: + file_path: Relative path within the skills directory (e.g., "calendar/SKILL.md"). + ranges: Optional [start_line, end_line] to read a specific line range (1-indexed). + +Returns: + ToolResponse with the file content. +""" + return _view_skill_file + + +def _error_response(code: str, message: str) -> ToolResponse: + return ToolResponse( + content=[ + TextBlock( + type="text", + text=f"error: {code} - {message}", + ) + ] + ) diff --git a/backend/src/core/agentscope/tools/skills/calendar/SKILL.md b/backend/src/core/agentscope/tools/skills/calendar/SKILL.md new file mode 100644 index 0000000..ab5a5df --- /dev/null +++ b/backend/src/core/agentscope/tools/skills/calendar/SKILL.md @@ -0,0 +1,121 @@ +--- +name: calendar +description: Calendar event management - read, create, update, delete, and share events. +--- + +# Calendar Skill + +## Execution Protocol + +1. On first calendar use in a run, call `view_skill_file` with `calendar/SKILL.md` before any `project_cli` call. +2. After reading, use `project_cli` only with `command="calendar"`. +3. If the user asks for actual schedule data, use `project_cli` to verify it. Do not guess results. + +## When to Use + +- User asks about their schedule or upcoming events +- User wants to create, update, or delete calendar events +- User wants to share a calendar event with someone +- User asks about event details within a date range + +## Available Tool + +Use the single tool `project_cli`. + +Read this file first with `view_skill_file` when calendar is the relevant skill. + +### Read Events + +Call `project_cli` with: + +```json +{ + "command": "calendar", + "subcommand": "read", + "args": { + "start_at": "2026-04-21T00:00:00+08:00", + "end_at": "2026-04-22T00:00:00+08:00" + } +} +``` + +Use this whenever the user asks what is scheduled, free, upcoming, or happening in a time range. + +### Create Event + +Call `project_cli` with: + +```json +{ + "command": "calendar", + "subcommand": "create", + "args": { + "title": "Project sync", + "start_at": "2026-04-21T10:00:00+08:00", + "end_at": "2026-04-21T11:00:00+08:00", + "event_timezone": "Asia/Shanghai" + } +} +``` + +### Update Event + +Call `project_cli` with: + +```json +{ + "command": "calendar", + "subcommand": "update", + "args": { + "event_id": "", + "title": "Updated title" + } +} +``` + +### Delete Event + +Call `project_cli` with: + +```json +{ + "command": "calendar", + "subcommand": "delete", + "args": { + "event_id": "" + } +} +``` + +Read first if you need to confirm the write payload shape instead of relying on memory. + +### Share Events + +Call `project_cli` with: + +```json +{ + "command": "calendar", + "subcommand": "share", + "args": { + "event_id": "", + "invitees": [] + } +} +``` + +## Composition Patterns + +1. To share an event with a friend: + - Call `view_skill_file` with `contacts/SKILL.md` if contacts instructions have not been read in this run + - Call `project_cli` `contacts read` to find friend phone numbers + - Call `project_cli` `calendar share` with the selected phone + +2. To update a specific event: + - Call `project_cli` `calendar read` to find the event_id + - Call `project_cli` `calendar update` with target fields + +## Failure Recovery + +- If `calendar create/update/delete` returns failure, report why and suggest retrying with corrected parameters. +- If `calendar share` fails for a phone, suggest verifying the phone number with `contacts read`. diff --git a/backend/src/core/agentscope/tools/skills/contacts/SKILL.md b/backend/src/core/agentscope/tools/skills/contacts/SKILL.md new file mode 100644 index 0000000..1d95c8c --- /dev/null +++ b/backend/src/core/agentscope/tools/skills/contacts/SKILL.md @@ -0,0 +1,53 @@ +--- +name: contacts +description: Contact lookup - find friend information including phone numbers for calendar sharing. +--- + +# Contacts Skill + +## Execution Protocol + +1. On first contacts use in a run, call `view_skill_file` with `contacts/SKILL.md` before any `project_cli` call. +2. After reading, use `project_cli` only with `command="contacts"`. +3. If contact data is needed for a later action, fetch it first instead of inventing phone numbers or friend matches. + +## When to Use + +- User wants to share something with a friend but needs their contact info +- Agent needs phone numbers to pass to `calendar share` +- User asks about their friend list + +## Available Tool + +Use the single tool `project_cli`. + +Read this file first with `view_skill_file` when contacts is the relevant skill. + +### Read Contacts + +Call `project_cli` with: + +```json +{ + "command": "contacts", + "subcommand": "read", + "args": {} +} +``` + +Returns: +- `friends_count`: Total number of friends +- `friends`: List of `{userId, username, phone}` + +## Composition Patterns + +1. To share an event: + - Call `view_skill_file` with `calendar/SKILL.md` if calendar instructions have not been read in this run + - Call `project_cli` `contacts read` to get friend candidates + - Match user's description to a friend + - Call `project_cli` `calendar share` with the friend's phone + +## Failure Recovery + +- If no friends found, inform the user they have no contacts yet +- If lookup fails, suggest retrying diff --git a/backend/src/core/agentscope/tools/skills/memory/SKILL.md b/backend/src/core/agentscope/tools/skills/memory/SKILL.md new file mode 100644 index 0000000..02b8282 --- /dev/null +++ b/backend/src/core/agentscope/tools/skills/memory/SKILL.md @@ -0,0 +1,64 @@ +--- +name: memory +description: User memory management - store and forget personal facts and work profile information. +--- + +# Memory Skill + +## Execution Protocol + +1. On first memory use in a run, call `view_skill_file` with `memory/SKILL.md` before any `project_cli` call. +2. After reading, use `project_cli` only with `command="memory"`. +3. If the user asks to remember or forget something, execute `project_cli`; do not claim persistence without the tool result. + +## When to Use + +- User shares personal preferences, habits, or facts they want remembered +- User wants to update their work profile (company, role, skills) +- User wants to remove previously stored information +- Agent needs to recall user preferences for personalization + +## Available Tool + +Use the single tool `project_cli`. + +Read this file first with `view_skill_file` when memory is the relevant skill. + +### Update Memory + +Call `project_cli` with: + +```json +{ + "command": "memory", + "subcommand": "update", + "args": { + "operations": [ + { + "action": "update", + "memory_type": "user", + "user_content": {} + } + ] + } +} +``` + +Operation object fields: +- `action`: `update` or `delete` +- `memory_type`: `user` or `work` +- `update` requires matching content payload (`user_content` / `work_content`) +- `delete` requires `forget_paths` + +## Composition Patterns + +1. When user says "remember that I prefer morning meetings": + - Call `project_cli` `memory update` with `action=update`, `memory_type=user`, and appropriate content + +2. When user says "forget my old address": + - Call `project_cli` `memory update` with `action=delete` and the specific dot-path + +## Failure Recovery + +- If write fails, inform the user and suggest rephrasing +- If forget path is invalid, suggest checking the data structure diff --git a/backend/src/core/agentscope/tools/tool_call_context.py b/backend/src/core/agentscope/tools/tool_call_context.py index cfcb43f..4a5e3a4 100644 --- a/backend/src/core/agentscope/tools/tool_call_context.py +++ b/backend/src/core/agentscope/tools/tool_call_context.py @@ -1,6 +1,7 @@ from __future__ import annotations from contextvars import ContextVar, Token +from typing import Any from uuid import uuid4 _CURRENT_TOOL_CALL_ID: ContextVar[str | None] = ContextVar( @@ -8,6 +9,11 @@ _CURRENT_TOOL_CALL_ID: ContextVar[str | None] = ContextVar( default=None, ) +_TOOL_AGENT_OUTPUT_STORE: ContextVar[dict[str, dict[str, Any]] | None] = ContextVar( + "tool_agent_output_store", + default=None, +) + def set_current_tool_call_id(tool_call_id: str | None) -> Token[str | None]: return _CURRENT_TOOL_CALL_ID.set(tool_call_id) @@ -22,3 +28,30 @@ def get_current_tool_call_id(*, tool_name: str) -> str: if isinstance(current, str) and current.strip(): return current.strip() return f"{tool_name}-call-{uuid4().hex}" + + +def _ensure_store() -> dict[str, dict[str, Any]]: + store = _TOOL_AGENT_OUTPUT_STORE.get() + if store is None: + store = {} + _TOOL_AGENT_OUTPUT_STORE.set(store) + return store + + +def store_tool_agent_output(*, tool_call_id: str, payload: dict[str, Any]) -> None: + store = _ensure_store() + store[tool_call_id] = payload + + +def peek_tool_agent_output(*, tool_call_id: str) -> dict[str, Any] | None: + store = _TOOL_AGENT_OUTPUT_STORE.get() + if store is None: + return None + return store.get(tool_call_id) + + +def consume_tool_agent_output(*, tool_call_id: str) -> dict[str, Any] | None: + store = _TOOL_AGENT_OUTPUT_STORE.get() + if store is None: + return None + return store.pop(tool_call_id, None) diff --git a/backend/src/core/agentscope/tools/tool_config.py b/backend/src/core/agentscope/tools/tool_config.py index c259a38..8c5a4a7 100644 --- a/backend/src/core/agentscope/tools/tool_config.py +++ b/backend/src/core/agentscope/tools/tool_config.py @@ -1,16 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from enum import Enum - - -class AgentTool(str, Enum): - CALENDAR_READ = "calendar.read" - CALENDAR_WRITE = "calendar.write" - CALENDAR_SHARE = "calendar.share" - USER_LOOKUP = "user.lookup" - MEMORY_WRITE = "memory.write" - MEMORY_FORGET = "memory.forget" @dataclass(frozen=True) @@ -25,68 +15,12 @@ class ToolConfig: TOOL_CONFIGS: dict[str, ToolConfig] = { - "calendar_read": ToolConfig( - name="calendar_read", + "project_cli": ToolConfig( + name="project_cli", approval=ToolApprovalConfig(required=False), ), - "user_lookup": ToolConfig( - name="user_lookup", - approval=ToolApprovalConfig(required=False), - ), - "calendar_write": ToolConfig( - name="calendar_write", - approval=ToolApprovalConfig(required=False), - ), - "calendar_share": ToolConfig( - name="calendar_share", - approval=ToolApprovalConfig(required=False), - ), - "memory_write": ToolConfig( - name="memory_write", - approval=ToolApprovalConfig(required=False), - ), - "memory_forget": ToolConfig( - name="memory_forget", + "view_skill_file": ToolConfig( + name="view_skill_file", approval=ToolApprovalConfig(required=False), ), } - -AGENT_TOOL_TO_FUNCTION_NAME: dict[AgentTool, str] = { - AgentTool.CALENDAR_READ: "calendar_read", - AgentTool.CALENDAR_WRITE: "calendar_write", - AgentTool.CALENDAR_SHARE: "calendar_share", - AgentTool.USER_LOOKUP: "user_lookup", - AgentTool.MEMORY_WRITE: "memory_write", - AgentTool.MEMORY_FORGET: "memory_forget", -} - -TOOL_NAME_ALIASES: dict[str, AgentTool] = { - AgentTool.CALENDAR_READ.value: AgentTool.CALENDAR_READ, - "calendar_read": AgentTool.CALENDAR_READ, - AgentTool.CALENDAR_WRITE.value: AgentTool.CALENDAR_WRITE, - "calendar_write": AgentTool.CALENDAR_WRITE, - AgentTool.CALENDAR_SHARE.value: AgentTool.CALENDAR_SHARE, - "calendar_share": AgentTool.CALENDAR_SHARE, - AgentTool.USER_LOOKUP.value: AgentTool.USER_LOOKUP, - "user_lookup": AgentTool.USER_LOOKUP, - AgentTool.MEMORY_WRITE.value: AgentTool.MEMORY_WRITE, - "memory_write": AgentTool.MEMORY_WRITE, - AgentTool.MEMORY_FORGET.value: AgentTool.MEMORY_FORGET, - "memory_forget": AgentTool.MEMORY_FORGET, -} - - -def parse_agent_tool(value: object) -> AgentTool: - if isinstance(value, AgentTool): - return value - raw_value = str(value or "").strip().lower() - if not raw_value: - raise ValueError("enabled tool value cannot be empty") - tool = TOOL_NAME_ALIASES.get(raw_value) - if tool is None: - raise ValueError(f"unknown enabled tool: {raw_value}") - return tool - - -def resolve_tool_function_names(tools: set[AgentTool]) -> set[str]: - return {AGENT_TOOL_TO_FUNCTION_NAME[tool] for tool in tools} diff --git a/backend/src/core/agentscope/tools/tool_middleware.py b/backend/src/core/agentscope/tools/tool_middleware.py index 4b445b2..d905dc7 100644 --- a/backend/src/core/agentscope/tools/tool_middleware.py +++ b/backend/src/core/agentscope/tools/tool_middleware.py @@ -1,42 +1,18 @@ from __future__ import annotations from typing import Any, AsyncGenerator, Callable -from uuid import uuid4 from core.agentscope.tools.tool_call_context import ( reset_current_tool_call_id, set_current_tool_call_id, ) -from core.agentscope.tools.tool_config import ( - AGENT_TOOL_TO_FUNCTION_NAME, - TOOL_CONFIGS, - ToolConfig, - parse_agent_tool, -) -from core.agentscope.tools.utils.tool_response_builder import ( - build_error_response, -) -def register_tool_middlewares( - *, - toolkit: Any, - config_by_name: dict[str, ToolConfig] | None = None, - meta_by_name: dict[str, ToolConfig] | None = None, - approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None] - | None = None, -) -> None: - effective_config = config_by_name or meta_by_name or TOOL_CONFIGS - toolkit.register_middleware(create_tool_call_context_middleware()) - toolkit.register_middleware( - create_approval_middleware( - config_by_name=effective_config, - approval_resolver=approval_resolver, - ) - ) +def register_tool_middlewares(*, toolkit: Any) -> None: + toolkit.register_middleware(_create_tool_call_context_middleware()) -def create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, None]]: +def _create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, None]]: async def tool_call_context_middleware( kwargs: dict[str, Any], next_handler: Callable[..., Any], @@ -56,98 +32,3 @@ def create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, N reset_current_tool_call_id(token) return tool_call_context_middleware - - -def create_approval_middleware( - *, - config_by_name: dict[str, ToolConfig], - approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None] - | None = None, -) -> Callable[..., AsyncGenerator[Any, None]]: - def _resolve_tool_config(*, tool_name: str) -> ToolConfig | None: - config = config_by_name.get(tool_name) - if config is not None: - return config - try: - normalized_tool_name = AGENT_TOOL_TO_FUNCTION_NAME[ - parse_agent_tool(tool_name) - ] - except ValueError: - return None - return config_by_name.get(normalized_tool_name) - - def _resolve_tool_call_id(tool_call: dict[str, Any]) -> str: - raw_tool_call_id = tool_call.get("id") - if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip(): - return raw_tool_call_id.strip() - return f"tool-call-{uuid4().hex}" - - async def approval_middleware( - kwargs: dict[str, Any], - next_handler: Callable[..., Any], - ) -> AsyncGenerator[Any, None]: - tool_call = kwargs.get("tool_call") - if not isinstance(tool_call, dict): - async for response in await next_handler(**kwargs): - yield response - return - - tool_name = tool_call.get("name") - if not isinstance(tool_name, str): - async for response in await next_handler(**kwargs): - yield response - return - - config = _resolve_tool_config(tool_name=tool_name) - if config is None or not config.approval.required: - async for response in await next_handler(**kwargs): - yield response - return - - tool_input = tool_call.get("input") - tool_args = tool_input if isinstance(tool_input, dict) else {} - decision = ( - approval_resolver(tool_name, tool_args, config) - if approval_resolver - else None - ) - - if decision == "approved": - sanitized_args = { - key: value for key, value in tool_args.items() if key != "_hitl" - } - next_call = {**tool_call, "input": sanitized_args} - next_kwargs = {**kwargs, "tool_call": next_call} - async for response in await next_handler(**next_kwargs): - yield response - return - - if decision == "rejected": - content = build_error_response( - tool_name=tool_name, - tool_call_id=_resolve_tool_call_id(tool_call), - code="TOOL_REJECTED", - message=f"工具 {tool_name} 的调用已被审核拒绝", - retryable=False, - details={ - "tool": tool_name, - "status": "rejected", - }, - ) - yield content - return - - pending_response = build_error_response( - tool_name=tool_name, - tool_call_id=_resolve_tool_call_id(tool_call), - code="TOOL_PENDING_APPROVAL", - message=f"工具 {tool_name} 需要审核批准", - retryable=True, - details={ - "tool": tool_name, - "status": "pending", - }, - ) - yield pending_response - - return approval_middleware diff --git a/backend/src/core/agentscope/tools/tool_postprocessor.py b/backend/src/core/agentscope/tools/tool_postprocessor.py new file mode 100644 index 0000000..6867b0e --- /dev/null +++ b/backend/src/core/agentscope/tools/tool_postprocessor.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus +from schemas.agent.ui_hints import UiHintIntent, UiHintsPayload, UiHintStatus + + +def _resolve_command_key(tool_output: ToolAgentOutput) -> tuple[str, str] | None: + args = tool_output.tool_call_args or {} + command = str(args.get("command", "")).strip() + subcommand = str(args.get("subcommand", "")).strip() + if command and subcommand: + return command, subcommand + result = tool_output.result + if isinstance(result, dict): + command = str(result.get("command", "")).strip() + subcommand = str(result.get("subcommand", "")).strip() + if command and subcommand: + return command, subcommand + return None + + +def _result_data(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + result = tool_output.result + if not isinstance(result, dict): + return None + data = result.get("data") + return data if isinstance(data, dict) else None + + +def _status_from_tool(tool_output: ToolAgentOutput) -> UiHintStatus: + if tool_output.status == ToolStatus.SUCCESS: + return UiHintStatus.SUCCESS + if tool_output.status == ToolStatus.PARTIAL: + return UiHintStatus.WARNING + if tool_output.status == ToolStatus.FAILURE: + return UiHintStatus.ERROR + return UiHintStatus.INFO + + +def _status_from_result_item(value: object) -> UiHintStatus: + text = str(value or "").strip().lower() + if text == "success": + return UiHintStatus.SUCCESS + if text == "partial": + return UiHintStatus.WARNING + if text == "failure": + return UiHintStatus.ERROR + return UiHintStatus.INFO + + +def _build_status_ui_hints( + *, + tool_output: ToolAgentOutput, + intent: UiHintIntent, + title: str, + description: str, + items: list[dict[str, Any]], + list_title: str, + list_items: list[dict[str, Any]], +) -> dict[str, Any]: + payload = UiHintsPayload.model_validate( + { + "intent": intent, + "status": _status_from_tool(tool_output), + "title": title, + "description": description, + "items": items, + "sections": [{"title": list_title, "listItems": list_items}], + } + ) + return payload.model_dump(mode="json", by_alias=True, exclude_none=True) + + +def _results_list(data: dict[str, Any]) -> list[dict[str, Any]]: + raw = data.get("results") + return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else [] + + +def _calendar_read_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + data = _result_data(tool_output) + if data is None: + return None + + items_raw = data.get("items") + events = [item for item in items_raw if isinstance(item, dict)] if isinstance(items_raw, list) else [] + list_items: list[dict[str, Any]] = [] + + for event in events: + event_id = str(event.get("id") or "").strip() + title = str(event.get("title") or "").strip() + start_at = str(event.get("startAt") or "").strip() + end_at = str(event.get("endAt") or "").strip() + subtitle = f"{start_at} ~ {end_at}" if start_at and end_at else (start_at or end_at or None) + list_items.append( + { + "id": event_id or None, + "title": title or "日程", + "subtitle": subtitle, + "status": UiHintStatus.INFO.value, + } + ) + + return _build_status_ui_hints( + tool_output=tool_output, + intent=UiHintIntent.LIST, + title="日程查询结果", + description="仅展示本次查询返回的日程列表。", + items=[ + {"key": "total", "label": "日程数量", "value": int(data.get("total") or len(events))}, + ], + list_title="日程列表", + list_items=list_items, + ) + + +def _calendar_mutation_ui_hints( + *, + tool_output: ToolAgentOutput, + action_label: str, +) -> dict[str, Any] | None: + data = _result_data(tool_output) + if data is None: + return None + + success_count = int(data.get("success") or 0) + failed_count = int(data.get("failed") or 0) + list_items: list[dict[str, Any]] = [] + + for item in _results_list(data): + event_id = str(item.get("eventId") or "").strip() + status = _status_from_result_item(item.get("status")).value + code = str(item.get("code") or "").strip() + changed_fields = item.get("changedFields") + field_text = ( + ",".join([str(field).strip() for field in changed_fields if str(field).strip()]) + if isinstance(changed_fields, list) + else "" + ) + subtitle_parts: list[str] = [] + if event_id: + subtitle_parts.append(f"event_id={event_id}") + if field_text: + subtitle_parts.append(f"fields={field_text}") + if code: + subtitle_parts.append(f"code={code}") + list_items.append( + { + "id": event_id or None, + "title": f"日程{action_label}", + "subtitle": " / ".join(subtitle_parts) if subtitle_parts else None, + "status": status, + } + ) + + return _build_status_ui_hints( + tool_output=tool_output, + intent=UiHintIntent.STATUS, + title=f"日程{action_label}结果", + description=f"仅展示本次日程{action_label}调用结果。", + items=[ + {"key": "success", "label": "成功", "value": success_count}, + {"key": "failed", "label": "失败", "value": failed_count}, + { + "key": "status", + "label": "总体状态", + "value": str(data.get("status") or tool_output.status.value), + }, + ], + list_title="执行明细", + list_items=list_items, + ) + + +def _calendar_create_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="创建") + + +def _calendar_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="更新") + + +def _calendar_delete_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="删除") + + +def _calendar_share_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + data = _result_data(tool_output) + if data is None: + return None + + success_count = int(data.get("success") or 0) + failed_count = int(data.get("failed") or 0) + list_items: list[dict[str, Any]] = [] + + for item in _results_list(data): + phone = str(item.get("phone") or "").strip() + status = _status_from_result_item(item.get("status")).value + code = str(item.get("code") or "").strip() + subtitle_parts: list[str] = [] + if phone: + subtitle_parts.append(f"phone={phone}") + if code: + subtitle_parts.append(f"code={code}") + list_items.append( + { + "id": phone or None, + "title": "邀请分享", + "subtitle": " / ".join(subtitle_parts) if subtitle_parts else None, + "status": status, + } + ) + + return _build_status_ui_hints( + tool_output=tool_output, + intent=UiHintIntent.STATUS, + title="日程分享结果", + description="仅展示本次日程分享调用结果。", + items=[ + {"key": "success", "label": "成功", "value": success_count}, + {"key": "failed", "label": "失败", "value": failed_count}, + { + "key": "status", + "label": "总体状态", + "value": str(data.get("status") or tool_output.status.value), + }, + ], + list_title="分享明细", + list_items=list_items, + ) + + +def _memory_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + data = _result_data(tool_output) + if data is None: + return None + + success_count = int(data.get("success") or 0) + failed_count = int(data.get("failed") or 0) + updated_types = data.get("updated_types") + updated = ", ".join(updated_types) if isinstance(updated_types, list) else "" + forgotten_total = int(data.get("forgotten") or 0) + list_items: list[dict[str, Any]] = [] + + for item in _results_list(data): + memory_type = str(item.get("memoryType") or "memory").strip() + memory_id = str(item.get("memoryId") or "").strip() + action = str(item.get("action") or "update").strip() + forgotten = int(item.get("forgotten") or 0) + status = _status_from_result_item(item.get("status")).value + subtitle_parts: list[str] = [] + if memory_id: + subtitle_parts.append(f"memory_id={memory_id}") + if action: + subtitle_parts.append(f"action={action}") + if forgotten: + subtitle_parts.append(f"forgotten={forgotten}") + list_items.append( + { + "id": memory_id or None, + "title": f"{memory_type} memory", + "subtitle": " / ".join(subtitle_parts) if subtitle_parts else None, + "status": status, + } + ) + + return _build_status_ui_hints( + tool_output=tool_output, + intent=UiHintIntent.STATUS, + title="记忆更新结果", + description="仅展示本次 memory.update 的结构化状态。", + items=[ + {"key": "success", "label": "成功", "value": success_count}, + {"key": "failed", "label": "失败", "value": failed_count}, + { + "key": "updated_types", + "label": "已更新类型", + "value": updated, + }, + {"key": "forgotten", "label": "已清理条目", "value": forgotten_total}, + ], + list_title="执行明细", + list_items=list_items, + ) + + +def _contacts_read_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + data = _result_data(tool_output) + if data is None: + return None + + contacts_raw = data.get("friends") + contacts = [item for item in contacts_raw if isinstance(item, dict)] if isinstance(contacts_raw, list) else [] + list_items: list[dict[str, Any]] = [] + + for item in contacts: + user_id = str(item.get("userId") or "").strip() + username = str(item.get("username") or "").strip() + phone = str(item.get("phone") or "").strip() + list_items.append( + { + "id": user_id or None, + "title": username or phone or "联系人", + "subtitle": phone or None, + "status": UiHintStatus.INFO.value, + } + ) + + return _build_status_ui_hints( + tool_output=tool_output, + intent=UiHintIntent.LIST, + title="联系人读取结果", + description="仅展示当前可用联系人列表。", + items=[ + { + "key": "friends_count", + "label": "联系人数量", + "value": int(data.get("friends_count") or len(contacts)), + } + ], + list_title="联系人列表", + list_items=list_items, + ) + + +_UI_HINTS_BUILDERS: dict[tuple[str, str], Callable[[ToolAgentOutput], dict[str, Any] | None]] = { + ("calendar", "create"): _calendar_create_ui_hints, + ("calendar", "read"): _calendar_read_ui_hints, + ("calendar", "update"): _calendar_update_ui_hints, + ("calendar", "delete"): _calendar_delete_ui_hints, + ("calendar", "share"): _calendar_share_ui_hints, + ("contacts", "read"): _contacts_read_ui_hints, + ("memory", "update"): _memory_update_ui_hints, +} + + +def postprocess_tool_output(tool_output: ToolAgentOutput) -> ToolAgentOutput: + if tool_output.status == ToolStatus.FAILURE: + return tool_output + if tool_output.ui_hints is not None: + return tool_output + command_key = _resolve_command_key(tool_output) + if command_key is None: + return tool_output + builder = _UI_HINTS_BUILDERS.get(command_key) + if builder is None: + return tool_output + ui_hints = builder(tool_output) + if ui_hints is None: + return tool_output + return tool_output.model_copy(update={"ui_hints": ui_hints}) diff --git a/backend/src/core/agentscope/tools/tool_result_storage.py b/backend/src/core/agentscope/tools/tool_result_storage.py deleted file mode 100644 index a743dfc..0000000 --- a/backend/src/core/agentscope/tools/tool_result_storage.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import json -from typing import Protocol - -from services.base.supabase import supabase_service - - -class ToolResultStorage(Protocol): - async def upload_json( - self, - *, - bucket: str, - path: str, - payload: dict[str, object], - ) -> str: ... - - async def read_json( - self, - *, - bucket: str, - path: str, - ) -> dict[str, object] | None: ... - - -class SupabaseToolResultStorage: - async def upload_json( - self, - *, - bucket: str, - path: str, - payload: dict[str, object], - ) -> str: - serialized = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) - await supabase_service.upload_bytes( - bucket=bucket, - path=path, - content=serialized.encode("utf-8"), - content_type="application/json", - ) - return path - - async def read_json( - self, - *, - bucket: str, - path: str, - ) -> dict[str, object] | None: - raw = await supabase_service.download_bytes(bucket=bucket, path=path) - decoded = json.loads(raw.decode("utf-8")) - if isinstance(decoded, dict): - return decoded - return None - - -def create_tool_result_storage() -> ToolResultStorage: - return SupabaseToolResultStorage() diff --git a/backend/src/core/agentscope/tools/toolkit.py b/backend/src/core/agentscope/tools/toolkit.py index 13262ac..f00cf8d 100644 --- a/backend/src/core/agentscope/tools/toolkit.py +++ b/backend/src/core/agentscope/tools/toolkit.py @@ -1,110 +1,90 @@ from __future__ import annotations -from typing import Any, cast -from uuid import UUID +from pathlib import Path +from typing import Any -from agentscope.tool import Toolkit -from agentscope.types import JSONSerializableObject -from core.agentscope.tools.custom.calendar import ( - calendar_read, - calendar_share, - calendar_write, -) -from core.agentscope.tools.custom.memory import ( - memory_forget, - memory_write, -) -from core.agentscope.tools.custom.user_lookup import user_lookup -from core.agentscope.tools.tool_config import ( - TOOL_CONFIGS, -) +from core.agentscope.tools.internal import make_project_cli_wrapper, make_view_skill_file_wrapper +from core.agentscope.tools.internal.project_cli import PROJECT_CLI_TOOL_NAME +from core.agentscope.tools.internal.view_skill_file import VIEW_SKILL_FILE_TOOL_NAME from core.agentscope.tools.tool_middleware import register_tool_middlewares -from sqlalchemy.ext.asyncio import AsyncSession -from schemas.agent.system_agent import AgentType +from core.logging import get_logger +from schemas.agent.skill_config import ProjectCliCommand, SkillName -TOOL_FUNCTIONS: dict[str, Any] = { - "calendar_read": calendar_read, - "calendar_write": calendar_write, - "calendar_share": calendar_share, - "user_lookup": user_lookup, - "memory_write": memory_write, - "memory_forget": memory_forget, -} +_logger = get_logger("core.agentscope.tools.toolkit") + +SKILLS_DIR = Path(__file__).parent / "skills" -AGENT_TYPE_TO_DEFAULT_TOOLS: dict[AgentType, set[str]] = { - AgentType.WORKER: { - "calendar_read", - "calendar_write", - "calendar_share", - "user_lookup", - }, -} +def _all_skill_names() -> set[str]: + return {skill.value for skill in SkillName} -def _validate_enabled_tool_names(enabled_tool_names: set[str]) -> set[str]: - unknown = enabled_tool_names - set(TOOL_FUNCTIONS) +def _validate_enabled_skill_names(skill_names: set[str]) -> set[str]: + unknown = skill_names - _all_skill_names() if unknown: - raise ValueError(f"unknown tools in enabled_tool_names: {sorted(unknown)}") - return enabled_tool_names + raise ValueError(f"unknown skills in enabled_skill_names: {sorted(unknown)}") + return skill_names + + +def _all_command_names() -> set[str]: + return {command.value for command in ProjectCliCommand} + + +def _validate_allowed_commands(command_names: set[str]) -> set[str]: + unknown = command_names - _all_command_names() + if unknown: + raise ValueError(f"unknown commands in allowed_commands: {sorted(unknown)}") + return command_names def build_toolkit( *, - session: AsyncSession, - owner_id: UUID, - enabled_tool_names: set[str] | None = None, + enabled_skill_names: set[str] | None = None, + allowed_commands: set[str] | None = None, enable_hitl: bool | None = None, -): - toolkit = Toolkit() - if enabled_tool_names is None: - enabled_names = set(TOOL_FUNCTIONS) - else: - enabled_names = _validate_enabled_tool_names(set(enabled_tool_names)) +) -> Any: + from agentscope.tool import Toolkit - preset_kwargs = cast( - dict[str, JSONSerializableObject], - { - "session": session, - "owner_id": owner_id, - }, + if enabled_skill_names is None: + enabled_skills = _all_skill_names() + else: + enabled_skills = _validate_enabled_skill_names(enabled_skill_names) + + toolkit = Toolkit() + + if allowed_commands is None: + resolved_allowed_commands = _all_command_names() + else: + resolved_allowed_commands = _validate_allowed_commands(allowed_commands) + + project_cli_wrapper = make_project_cli_wrapper( + allowed_commands=resolved_allowed_commands + ) + toolkit.register_tool_function( + project_cli_wrapper, + func_name=PROJECT_CLI_TOOL_NAME, ) - for tool_name in sorted(enabled_names): - tool_func = TOOL_FUNCTIONS[tool_name] - toolkit.register_tool_function( - tool_func, - func_name=tool_name, - preset_kwargs=preset_kwargs, - ) + view_skill_wrapper = make_view_skill_file_wrapper(enabled_skill_names=enabled_skills) + toolkit.register_tool_function( + view_skill_wrapper, + func_name=VIEW_SKILL_FILE_TOOL_NAME, + ) + + for skill_name in enabled_skills: + skill_dir = SKILLS_DIR / skill_name + if skill_dir.is_dir(): + try: + toolkit.register_agent_skill(str(skill_dir)) + except Exception as exc: + _logger.warning( + "failed_to_register_skill", + skill_name=skill_name, + error=str(exc), + ) approval_enabled = enable_hitl if enable_hitl is not None else True if approval_enabled: - register_tool_middlewares(toolkit=toolkit, config_by_name=TOOL_CONFIGS) + register_tool_middlewares(toolkit=toolkit) return toolkit - - -def build_stage_toolkit( - *, - agent_type: AgentType, - session: AsyncSession, - owner_id: UUID, - enabled_tool_names: set[str] | None = None, - enable_hitl: bool | None = None, -): - default_tools = AGENT_TYPE_TO_DEFAULT_TOOLS.get(agent_type) - if default_tools is None: - raise ValueError(f"unknown agent_type: {agent_type}") - selected_names = ( - set(default_tools) - if enabled_tool_names is None - else _validate_enabled_tool_names(set(enabled_tool_names)) - ) - - return build_toolkit( - session=session, - owner_id=owner_id, - enabled_tool_names=selected_names, - enable_hitl=enable_hitl, - ) diff --git a/backend/src/core/agentscope/tools/utils/tool_response_builder.py b/backend/src/core/agentscope/tools/utils/tool_response_builder.py index 0107f24..91e03ea 100644 --- a/backend/src/core/agentscope/tools/utils/tool_response_builder.py +++ b/backend/src/core/agentscope/tools/utils/tool_response_builder.py @@ -1,21 +1,20 @@ from __future__ import annotations -import json from typing import Any from agentscope.message import TextBlock from agentscope.tool import ToolResponse +from core.agentscope.utils.parsing import project_tool_result_text from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus def build_tool_response(content: ToolAgentOutput) -> ToolResponse: - """Wrap ToolAgentOutput into AgentScope ToolResponse.""" - payload = content.model_dump(mode="json", exclude_none=True) + text = project_tool_result_text(content.result) return ToolResponse( content=[ TextBlock( type="text", - text=json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + text=text, ) ] ) @@ -29,12 +28,11 @@ def build_error_output( retryable: bool = False, details: dict[str, Any] | None = None, ) -> ToolAgentOutput: - """Build a ToolAgentOutput in failure status.""" return ToolAgentOutput( tool_name=tool_name, tool_call_id=tool_call_id, status=ToolStatus.FAILURE, - result=f"status=failure code={code} message={message}", + result={"status": "failure", "code": code, "message": message}, error=ErrorInfo( code=code, message=message, @@ -52,7 +50,6 @@ def build_error_response( retryable: bool = False, details: dict[str, Any] | None = None, ) -> ToolResponse: - """Build standardized ToolResponse for error cases.""" return build_tool_response( build_error_output( tool_name=tool_name, diff --git a/backend/src/core/agentscope/utils/parsing.py b/backend/src/core/agentscope/utils/parsing.py index ee0b34b..99d62ef 100644 --- a/backend/src/core/agentscope/utils/parsing.py +++ b/backend/src/core/agentscope/utils/parsing.py @@ -4,10 +4,45 @@ import json from collections.abc import Sequence from typing import Any +from core.agentscope.tools.tool_call_context import consume_tool_agent_output +from core.logging import get_logger from schemas.agent.runtime_models import ToolAgentOutput +_logger = get_logger("core.agentscope.utils.parsing") + + +def project_tool_result_text(result: Any) -> str: + if result is None: + return "" + if isinstance(result, str): + return result + try: + return json.dumps(result, ensure_ascii=False, separators=(",", ":")) + except Exception: + return str(result) + + +def parse_tool_agent_output( + output: Any, + *, + tool_call_id: str | None = None, + tool_name: str | None = None, + tool_call_args: dict[str, Any] | None = None, +) -> ToolAgentOutput | None: + side_channel_payload: dict[str, Any] | None = None + if tool_call_id: + side_channel_payload = consume_tool_agent_output(tool_call_id=tool_call_id) + + if side_channel_payload is not None: + try: + return ToolAgentOutput.model_validate(side_channel_payload) + except Exception as exc: + _logger.warning( + "parse_tool_agent_output_side_channel_failed", + error=str(exc), + tool_call_id=tool_call_id, + ) -def parse_tool_agent_output(output: Any) -> ToolAgentOutput | None: blocks = output if isinstance(output, Sequence) else [] for block in blocks: if not isinstance(block, dict) or block.get("type") != "text": @@ -16,8 +51,20 @@ def parse_tool_agent_output(output: Any) -> ToolAgentOutput | None: if not isinstance(text, str) or not text.strip(): continue try: - return ToolAgentOutput.model_validate(json.loads(text)) - except Exception: + parsed = json.loads(text) + if tool_name and "tool_name" not in parsed: + parsed["tool_name"] = tool_name + if tool_call_id and "tool_call_id" not in parsed: + parsed["tool_call_id"] = tool_call_id + if tool_call_args and "tool_call_args" not in parsed: + parsed["tool_call_args"] = tool_call_args + return ToolAgentOutput.model_validate(parsed) + except Exception as exc: + _logger.warning( + "parse_tool_agent_output_failed", + error=str(exc), + text_preview=text[:200], + ) return None return None diff --git a/backend/src/core/auth/credential_issuer.py b/backend/src/core/auth/credential_issuer.py new file mode 100644 index 0000000..fb37e19 --- /dev/null +++ b/backend/src/core/auth/credential_issuer.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt + +from core.auth.jwt_verifier import TokenValidationError + +_AUDIENCE = "agent-tool-runtime" +_PURPOSE = "agent_tool_runtime" + + +class ToolCredentialIssuer: + def __init__( + self, + *, + jwt_secret: str, + jwt_algorithm: str, + jwt_issuer: str, + ttl_seconds: int, + ) -> None: + if jwt_algorithm != "HS256": + raise TokenValidationError("Unsupported JWT algorithm for tool credential") + self._jwt_secret = jwt_secret + self._jwt_algorithm = jwt_algorithm + self._jwt_issuer = jwt_issuer + self._ttl_seconds = ttl_seconds + + def issue(self, *, owner_id: str, mode: str = "chat") -> str: + now = datetime.now(timezone.utc) + payload: dict[str, Any] = { + "sub": owner_id, + "aud": _AUDIENCE, + "iss": self._jwt_issuer, + "iat": now, + "exp": now + timedelta(seconds=self._ttl_seconds), + "purpose": _PURPOSE, + "mode": mode, + } + return jwt.encode(payload, self._jwt_secret, algorithm=self._jwt_algorithm) + + def verify(self, token: str) -> dict[str, Any]: + try: + payload = jwt.decode( + token, + self._jwt_secret, + algorithms=[self._jwt_algorithm], + options={ + "require": ["sub", "exp", "aud", "iss", "purpose"], + "verify_aud": True, + }, + audience=_AUDIENCE, + ) + except jwt.ExpiredSignatureError as exc: + raise TokenValidationError("Tool credential expired") from exc + except jwt.InvalidSignatureError as exc: + raise TokenValidationError("Tool credential signature invalid") from exc + except jwt.InvalidAlgorithmError as exc: + raise TokenValidationError("Tool credential algorithm invalid") from exc + except jwt.DecodeError as exc: + raise TokenValidationError("Tool credential decode failed") from exc + except jwt.PyJWTError as exc: + raise TokenValidationError("Tool credential validation failed") from exc + + if payload.get("purpose") != _PURPOSE: + raise TokenValidationError("Tool credential purpose mismatch") + + token_issuer = payload.get("iss") + if token_issuer != self._jwt_issuer: + raise TokenValidationError( + f"Tool credential issuer mismatch: expected {self._jwt_issuer}, got {token_issuer}" + ) + + return payload + + +def create_credential_issuer() -> ToolCredentialIssuer: + from core.config.settings import config + + jwt_secret = config.supabase.jwt_secret + if jwt_secret is None: + raise TokenValidationError("JWT secret not configured for tool credential issuer") + + return ToolCredentialIssuer( + jwt_secret=jwt_secret.get_secret_value(), + jwt_algorithm=config.supabase.jwt_algorithm, + jwt_issuer=config.supabase.jwt_issuer or "", + ttl_seconds=config.agent_runtime.tool_credential_ttl_seconds, + ) diff --git a/backend/src/core/auth/tool_credential_context.py b/backend/src/core/auth/tool_credential_context.py new file mode 100644 index 0000000..757fc3f --- /dev/null +++ b/backend/src/core/auth/tool_credential_context.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from contextvars import ContextVar, Token + +_TOOL_CREDENTIAL: ContextVar[str | None] = ContextVar( + "tool_credential", + default=None, +) + + +def set_tool_credential(credential: str | None) -> Token[str | None]: + return _TOOL_CREDENTIAL.set(credential) + + +def reset_tool_credential(token: Token[str | None]) -> None: + _TOOL_CREDENTIAL.reset(token) + + +def get_tool_credential() -> str | None: + return _TOOL_CREDENTIAL.get() diff --git a/backend/src/core/automation/scheduler.py b/backend/src/core/automation/scheduler.py index dd38574..b269f26 100644 --- a/backend/src/core/automation/scheduler.py +++ b/backend/src/core/automation/scheduler.py @@ -63,14 +63,12 @@ async def _dispatch_automation_run( from ag_ui.core import RunAgentInput from core.auth.models import CurrentUser - from core.agentscope.tools.tool_result_storage import create_tool_result_storage from schemas.agent.forwarded_props import RuntimeMode from v1.agent.dependencies import TaskiqQueueClient, RedisEventStream from v1.agent.repository import AgentRepository from v1.agent.service import AgentService current_user = CurrentUser(id=owner_id) - tool_result_storage = create_tool_result_storage() run_input = { "threadId": str(thread_id), @@ -95,9 +93,7 @@ async def _dispatch_automation_run( from core.db.session import AsyncSessionLocal async with AsyncSessionLocal() as session: - repository = AgentRepository( - session=session, tool_result_storage=tool_result_storage - ) + repository = AgentRepository(session=session) service = AgentService( repository=repository, queue=TaskiqQueueClient(), diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 6e37dfd..a6fae53 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -89,11 +89,6 @@ class RuntimeSettings(BaseModel): return self -class AnalyticsSettings(BaseModel): - data_path: str = "backend/data/analytics" - password: str = "analytics-secret" - - class TaskiqSettings(BaseModel): broker_url: str | None = None result_backend_url: str | None = None @@ -186,6 +181,7 @@ class AgentRuntimeSettings(BaseModel): ge=1024, le=64 * 1024 * 1024, ) + tool_credential_ttl_seconds: int = Field(default=300, ge=30, le=3600) class AutomationSchedulerSettings(BaseModel): @@ -251,7 +247,7 @@ class AppVersionSettings(BaseModel): class TestSettings(BaseModel): phone: str = "" - password: str = "" + code: str = "" def _resolve_env_file() -> str: @@ -279,7 +275,6 @@ class Settings(BaseSettings): taskiq: TaskiqSettings = TaskiqSettings() database: DatabaseSettings = DatabaseSettings() app_version: AppVersionSettings = AppVersionSettings() - analytics: AnalyticsSettings = AnalyticsSettings() test: TestSettings = Field(default_factory=TestSettings) @computed_field diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml index c4abc4a..e000dd7 100644 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -19,9 +19,8 @@ input_template: | 表达风格: - 语言自然、温和、可读,像助理在做每日回顾。 - 结论先行,避免空话,不要输出与任务无关的闲聊内容。 -enabled_tools: - - memory.write - - memory.forget +enabled_skills: + - memory context: source: latest_chat window_mode: day diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml index 3b1d51d..3d3b0be 100644 --- a/backend/src/core/config/static/database/system_agents.yaml +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -9,7 +9,7 @@ agents: context_messages: mode: day count: 2 - enabled_tools: [] + enabled_skills: [] - agent_type: worker llm_model_code: qwen3.5-flash @@ -21,8 +21,11 @@ agents: context_messages: mode: number count: 20 - enabled_tools: - - calendar.read - - calendar.write - - calendar.share - - user.lookup + enabled_skills: + - calendar + - contacts + - memory + allowed_commands: + - calendar + - contacts + - memory diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py index 183634c..2422d0a 100644 --- a/backend/src/schemas/agent/__init__.py +++ b/backend/src/schemas/agent/__init__.py @@ -7,21 +7,12 @@ from schemas.agent.forwarded_props import ( from schemas.agent.forwarded_props import RuntimeMode from schemas.agent.runtime_models import ( AgentOutput, - ConstraintItem, - ExecutionMode, - KeyEntity, - NormalizedTaskInput, - ResultTyping, - ResultType, + ErrorInfo, RouterAgentOutput, RunStatus, - TaskType, - TaskTyping, ToolAgentOutput, ToolStatus, WorkerAgentOutputLite, - WorkerAgentOutputRich, - resolve_worker_output_model, ) from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_mask @@ -36,19 +27,12 @@ from schemas.agent.ui_hints import ( __all__ = [ "AgentType", "AgentOutput", - "ConstraintItem", - "ExecutionMode", - "ForwardedPropsPayload", - "KeyEntity", - "NormalizedTaskInput", - "ResultTyping", "ClientTimeContext", - "ResultType", + "ErrorInfo", + "ForwardedPropsPayload", "RouterAgentOutput", "RunStatus", "RuntimeMode", - "TaskType", - "TaskTyping", "SystemAgentLLMConfig", "SystemVisibilityBit", "ToolAgentOutput", @@ -60,9 +44,7 @@ __all__ = [ "UiHintsPayload", "VisibilityMask", "WorkerAgentOutputLite", - "WorkerAgentOutputRich", "bit_mask", "parse_forwarded_props_client_time", "parse_forwarded_props_runtime_mode", - "resolve_worker_output_model", ] diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index fb8c4a2..43be95e 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -3,64 +3,7 @@ from __future__ import annotations from enum import Enum from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator - -from schemas.agent.ui_hints import UiHintsPayload - - -class TaskType(str, Enum): - KNOWLEDGE = "knowledge" - RECOMMENDATION = "recommendation" - PLANNING = "planning" - SCHEDULING = "scheduling" - REMINDER_MANAGEMENT = "reminder_management" - TODO_MANAGEMENT = "todo_management" - COMMUNICATION_DRAFTING = "communication_drafting" - INFORMATION_ORGANIZATION = "information_organization" - STATUS_TRACKING = "status_tracking" - TRANSACTION_ASSIST = "transaction_assist" - ACTION_EXECUTION = "action_execution" - TROUBLESHOOTING = "troubleshooting" - UNKNOWN = "unknown" - - -class ResultType(str, Enum): - DIRECT_ANSWER = "direct_answer" - OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation" - ACTION_PLAN = "action_plan" - SCHEDULE_PROPOSAL = "schedule_proposal" - TODO_LIST = "todo_list" - DRAFT_MESSAGE = "draft_message" - SUMMARY = "summary" - PROGRESS_SUMMARY = "progress_summary" - DIAGNOSIS_REPORT = "diagnosis_report" - STRUCTURED_PAYLOAD = "structured_payload" - EXECUTION_REPORT = "execution_report" - CLARIFICATION_REQUEST = "clarification_request" - SAFETY_BLOCK = "safety_block" - ERROR_REPORT = "error_report" - UNKNOWN = "unknown" - - -class TaskTyping(BaseModel): - model_config = ConfigDict(extra="forbid") - - primary: TaskType - secondary: list[TaskType] = Field(default_factory=list, max_length=3) - - -class ResultTyping(BaseModel): - model_config = ConfigDict(extra="forbid") - - primary: ResultType - secondary: list[ResultType] = Field(default_factory=list, max_length=3) - - -class ExecutionMode(str, Enum): - ONESTEP = "onestep" - TOOL_ASSISTED = "tool_assisted" - MULTISTEP = "multistep" - +from pydantic import BaseModel, ConfigDict, Field class RunStatus(str, Enum): SUCCESS = "success" @@ -74,59 +17,6 @@ class ToolStatus(str, Enum): PARTIAL = "partial" -class KeyEntity(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str - type: str - value: str | None = None - - @field_validator("value", mode="before") - @classmethod - def normalize_value(cls, value: object) -> object: - if value is None: - return None - if isinstance(value, str): - return value - if isinstance(value, bool | int | float): - return str(value) - return value - - -class ConstraintItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - key: str - value: str - required: bool = True - - @field_validator("value", mode="before") - @classmethod - def normalize_value(cls, value: object) -> object: - if isinstance(value, bool | int | float): - return str(value) - return value - - -class NormalizedTaskInput(BaseModel): - model_config = ConfigDict(extra="forbid") - - user_text: str - multimodal_summary: list[str] = Field(default_factory=list) - context_summary: str = Field(default="", max_length=2000) - - -class RouterAgentOutput(BaseModel): - model_config = ConfigDict(extra="forbid") - - normalized_task_input: NormalizedTaskInput - key_entities: list[KeyEntity] = Field(default_factory=list) - constraints: list[ConstraintItem] = Field(default_factory=list) - task_typing: TaskTyping - execution_mode: ExecutionMode - result_typing: ResultTyping - - class ErrorInfo(BaseModel): model_config = ConfigDict(extra="forbid") @@ -136,6 +26,14 @@ class ErrorInfo(BaseModel): details: dict[str, Any] | None = None +class RouterAgentOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + objective: str + context_summary: str = "" + requires_tool_evidence: bool = False + + class ToolAgentOutput(BaseModel): model_config = ConfigDict(extra="forbid") @@ -143,8 +41,9 @@ class ToolAgentOutput(BaseModel): tool_call_id: str tool_call_args: dict[str, Any] | None = None status: ToolStatus - result: str + result: Any error: ErrorInfo | None = None + ui_hints: dict[str, Any] | None = None class WorkerAgentOutputLite(BaseModel): @@ -152,26 +51,12 @@ class WorkerAgentOutputLite(BaseModel): status: RunStatus = RunStatus.SUCCESS answer: str - key_points: list[str] = Field(default_factory=list) - result_type: ResultType = ResultType.UNKNOWN suggested_actions: list[str] = Field(default_factory=list) error: ErrorInfo | None = None -class WorkerAgentOutputRich(WorkerAgentOutputLite): - ui_hints: UiHintsPayload | None = None - - -class AgentOutput(WorkerAgentOutputRich): +class AgentOutput(WorkerAgentOutputLite): model_config = ConfigDict(extra="forbid") -WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich - - -def resolve_worker_output_model( - execution_mode: ExecutionMode, -) -> type[WorkerAgentOutputLite]: - if execution_mode == ExecutionMode.ONESTEP: - return WorkerAgentOutputLite - return WorkerAgentOutputRich +WorkerAgentOutput = WorkerAgentOutputLite diff --git a/backend/src/schemas/agent/skill_config.py b/backend/src/schemas/agent/skill_config.py new file mode 100644 index 0000000..1152b12 --- /dev/null +++ b/backend/src/schemas/agent/skill_config.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict + + +class SkillName(str, Enum): + CALENDAR = "calendar" + CONTACTS = "contacts" + MEMORY = "memory" + + +class ProjectCliCommand(str, Enum): + CALENDAR = "calendar" + CONTACTS = "contacts" + MEMORY = "memory" + + +class EnabledSkillConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: SkillName + + +def dedupe_enabled_skills(skills: list[EnabledSkillConfig]) -> list[EnabledSkillConfig]: + deduped: list[EnabledSkillConfig] = [] + seen: set[SkillName] = set() + for skill in skills: + if skill.name in seen: + continue + deduped.append(skill) + seen.add(skill.name) + return deduped + + +def enabled_skill_names(skills: list[EnabledSkillConfig]) -> list[str]: + return [skill.name.value for skill in dedupe_enabled_skills(skills)] diff --git a/backend/src/schemas/agent/system_agent.py b/backend/src/schemas/agent/system_agent.py index 92e41bb..0ebbb60 100644 --- a/backend/src/schemas/agent/system_agent.py +++ b/backend/src/schemas/agent/system_agent.py @@ -2,7 +2,7 @@ from __future__ import annotations from enum import Enum -from core.agentscope.tools.tool_config import AgentTool, parse_agent_tool +from schemas.agent.skill_config import ProjectCliCommand, SkillName from pydantic import BaseModel, Field, field_validator @@ -28,24 +28,48 @@ class SystemAgentLLMConfig(BaseModel): context_messages: ContextMessagesConfig = Field( default_factory=ContextMessagesConfig ) - enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32) + enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32) + allowed_commands: list[ProjectCliCommand] = Field( + default_factory=lambda: [command for command in ProjectCliCommand], + max_length=32, + ) - @field_validator("enabled_tools", mode="before") + @field_validator("enabled_skills", mode="before") @classmethod - def _normalize_enabled_tools(cls, value: object) -> list[AgentTool]: + def _normalize_enabled_skills(cls, value: object) -> list[SkillName]: if value is None: return [] if not isinstance(value, list): - raise ValueError("enabled_tools must be a list") - normalized: list[AgentTool] = [] + raise ValueError("enabled_skills must be a list") + normalized: list[SkillName] = [] for item in value: - if isinstance(item, AgentTool): - tool = item + if isinstance(item, SkillName): + skill = item else: raw_item = str(item or "").strip() if not raw_item: continue - tool = parse_agent_tool(raw_item) - if tool not in normalized: - normalized.append(tool) + skill = SkillName(raw_item) + if skill not in normalized: + normalized.append(skill) + return normalized + + @field_validator("allowed_commands", mode="before") + @classmethod + def _normalize_allowed_commands(cls, value: object) -> list[ProjectCliCommand]: + if value is None: + return [command for command in ProjectCliCommand] + if not isinstance(value, list): + raise ValueError("allowed_commands must be a list") + normalized: list[ProjectCliCommand] = [] + for item in value: + if isinstance(item, ProjectCliCommand): + command = item + else: + raw_item = str(item or "").strip() + if not raw_item: + continue + command = ProjectCliCommand(raw_item) + if command not in normalized: + normalized.append(command) return normalized diff --git a/backend/src/schemas/domain/automation.py b/backend/src/schemas/domain/automation.py index cfd757f..cc23a62 100644 --- a/backend/src/schemas/domain/automation.py +++ b/backend/src/schemas/domain/automation.py @@ -5,8 +5,8 @@ from enum import Enum from typing import Protocol from uuid import UUID -from core.agentscope.tools.tool_config import AgentTool from pydantic import BaseModel, ConfigDict, Field, model_validator +from schemas.agent.skill_config import ProjectCliCommand, SkillName from schemas.enums import AutomationJobStatus, ScheduleType @@ -73,14 +73,22 @@ class ScheduleConfig(BaseModel): class RuntimeConfig(BaseModel): model_config = ConfigDict(extra="forbid") - enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32) + enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32) + allowed_commands: list[ProjectCliCommand] = Field( + default_factory=lambda: [command for command in ProjectCliCommand], + max_length=32, + ) context: MessageContextConfig = Field(default_factory=MessageContextConfig) class AutomationJobConfig(BaseModel): model_config = ConfigDict(extra="forbid") - enabled_tools: list[AgentTool] | None = Field(default=None, max_length=32) + enabled_skills: list[SkillName] | None = Field(default=None, max_length=32) + allowed_commands: list[ProjectCliCommand] | None = Field( + default=None, + max_length=32, + ) context: MessageContextConfig | None = None input_template: str | None = Field(default=None, min_length=1, max_length=4000) schedule: ScheduleConfig | None = None diff --git a/backend/src/v1/agent/dependencies.py b/backend/src/v1/agent/dependencies.py index 37672a7..e0bda05 100644 --- a/backend/src/v1/agent/dependencies.py +++ b/backend/src/v1/agent/dependencies.py @@ -10,9 +10,6 @@ from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession from core.agentscope.events import RedisStreamBus -from core.agentscope.tools.tool_result_storage import ( - create_tool_result_storage, -) from core.config.settings import config from core.db import get_db from services.base.redis import get_or_init_redis_client @@ -141,9 +138,8 @@ class RedisEventStream: def get_agent_service(session: AsyncSession = Depends(get_db)) -> AgentService: - tool_result_storage = create_tool_result_storage() return AgentService( - repository=AgentRepository(session, tool_result_storage=tool_result_storage), + repository=AgentRepository(session), queue=TaskiqQueueClient(), stream=RedisEventStream(), attachment_storage=supabase_service, diff --git a/backend/src/v1/agent/repository.py b/backend/src/v1/agent/repository.py index 23d54a7..2452d0e 100644 --- a/backend/src/v1/agent/repository.py +++ b/backend/src/v1/agent/repository.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta, timezone from decimal import Decimal -from typing import Protocol from uuid import UUID, uuid4 from sqlalchemy import Select, select @@ -19,21 +18,9 @@ from schemas.domain.chat_message import ( ) -class ToolResultPayloadStorage(Protocol): - async def read_json( - self, *, bucket: str, path: str - ) -> dict[str, object] | None: ... - - class AgentRepository: - def __init__( - self, - session: AsyncSession, - *, - tool_result_storage: ToolResultPayloadStorage | None = None, - ) -> None: + def __init__(self, session: AsyncSession) -> None: self._session: AsyncSession = session - self._tool_result_storage: ToolResultPayloadStorage | None = tool_result_storage async def get_session_owner(self, *, session_id: str) -> str: try: diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index 236224d..501207f 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -159,17 +159,22 @@ class HistoryMessage(BaseModel): id: str = Field(description="Message UUID") seq: int = Field(description="Message sequence number") - role: Literal["user", "assistant"] = Field( - description="Message role: user | assistant" + role: Literal["user", "assistant", "tool"] = Field( + description="Message role: user | assistant | tool" ) content: str = Field(description="Message text content") + suggested_actions: list[str] = Field( + default_factory=list, + alias="suggestedActions", + description="Suggested follow-up prompts for assistant messages", + ) attachments: list[HistoryMessageAttachment] = Field( default_factory=list, description="Temporary signed URLs for user-attached images", ) ui_schema: UiSchemaRenderer | None = Field( default=None, - description="Compiled UI schema from worker ui_hints for frontend rendering", + description="UI schema payload when available in message data (assistant text does not generate ui_schema)", ) timestamp: str = Field(description="Message creation timestamp in ISO-8601 format") diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index f2259c7..5ec3c2d 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -530,8 +530,6 @@ class AgentService: ) for msg_dict in raw_messages: msg = AgentChatMessage.model_validate(msg_dict) - if msg.role == "tool": - continue signed_urls: dict[str, str] = {} attachments = extract_user_message_attachments(msg.metadata) diff --git a/backend/src/v1/agent/system_agents_config.py b/backend/src/v1/agent/system_agents_config.py index a9e3af5..913cb59 100644 --- a/backend/src/v1/agent/system_agents_config.py +++ b/backend/src/v1/agent/system_agents_config.py @@ -9,7 +9,6 @@ from pathlib import Path import yaml from pydantic import ValidationError -from core.agentscope.tools.tool_config import AgentTool from schemas.agent.system_agent import SystemAgentLLMConfig from schemas.domain.automation import ( ContextSource, @@ -17,6 +16,7 @@ from schemas.domain.automation import ( MessageContextConfig, RuntimeConfig, ) +from schemas.agent.skill_config import ProjectCliCommand def _default_system_agents_path() -> Path: @@ -67,7 +67,7 @@ def build_runtime_config_from_system_agents( chat 模式使用: - router.context_messages 配置 context - - worker.enabled_tools 配置 tools + - worker.enabled_skills 配置 skills """ raw = _load_system_agents_yaml(yaml_path) agents_list = raw.get("agents", []) @@ -94,11 +94,16 @@ def build_runtime_config_from_system_agents( router_config.context_messages.model_dump() if router_config else None ) - enabled_tools: list[AgentTool] = [] - if worker_config and worker_config.enabled_tools: - enabled_tools = list(worker_config.enabled_tools) + enabled_skills = [] + if worker_config and worker_config.enabled_skills: + enabled_skills = list(worker_config.enabled_skills) + + allowed_commands = [command for command in ProjectCliCommand] + if worker_config and worker_config.allowed_commands: + allowed_commands = list(worker_config.allowed_commands) return RuntimeConfig( - enabled_tools=enabled_tools, + enabled_skills=enabled_skills, + allowed_commands=allowed_commands, context=context_cfg, ) diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index c2344e9..66148b9 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -8,6 +8,7 @@ from collections.abc import Callable from typing import Any from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints +from schemas.agent.ui_hints import UiHintsPayload from schemas.domain.chat_message import ( AgentChatMessage, AgentChatMessageMetadata, @@ -29,38 +30,93 @@ def convert_message_to_history( 转换规则: - role=user: 读取 metadata.user_message_attachments,转换为 attachments[] - - role=assistant: 读取 metadata.agent_output.ui_hints,编译成 ui_schema + - role=assistant: 返回 answer 文本 + suggested_actions + - role=tool: 从 metadata.tool_agent_output.ui_hints 编译并返回 ui_schema """ role = message.role content = message.content metadata = message.metadata attachments: list[dict[str, str]] = [] - ui_schema: dict[str, Any] | None = None - if role == "user": attachments = _convert_user_attachments(metadata, get_signed_url_fn) - elif role == "assistant": - ui_schema = _compile_worker_ui_hints(metadata) - result: dict[str, Any] = { "id": str(message.id), "seq": message.seq, "role": role, "content": content, + "suggestedActions": _extract_suggested_actions(metadata), "timestamp": message.timestamp.isoformat(), } + ui_schema = _extract_tool_ui_schema(metadata) + if ui_schema is not None: + result["ui_schema"] = ui_schema + if attachments: result["attachments"] = attachments - if ui_schema: - result["ui_schema"] = ui_schema - return result +def _extract_suggested_actions( + metadata: AgentChatMessageMetadata | dict[str, Any] | None, +) -> list[str]: + if metadata is None: + return [] + + if isinstance(metadata, AgentChatMessageMetadata): + output = metadata.agent_output + if output is None: + return [] + actions = output.suggested_actions + elif isinstance(metadata, dict): + output = metadata.get("agent_output") + if not isinstance(output, dict): + return [] + actions = output.get("suggested_actions") + else: + return [] + + if not isinstance(actions, list): + return [] + normalized: list[str] = [] + for item in actions: + if not isinstance(item, str): + continue + text = item.strip() + if text: + normalized.append(text) + return normalized + + +def _extract_tool_ui_schema( + metadata: AgentChatMessageMetadata | dict[str, Any] | None, +) -> dict[str, Any] | None: + if metadata is None: + return None + + raw_ui_hints: Any = None + if isinstance(metadata, AgentChatMessageMetadata): + tool_output = metadata.tool_agent_output + if tool_output is not None: + raw_ui_hints = tool_output.ui_hints + elif isinstance(metadata, dict): + tool_output = metadata.get("tool_agent_output") + if isinstance(tool_output, dict): + raw_ui_hints = tool_output.get("ui_hints") + + if raw_ui_hints is None: + return None + + try: + ui_hints = UiHintsPayload.model_validate(raw_ui_hints) + return compile_ui_hints(ui_hints) + except Exception: + return None + + def _convert_user_attachments( metadata: AgentChatMessageMetadata | dict[str, Any] | None, get_signed_url_fn: Callable[[dict[str, str]], str] | None, @@ -93,44 +149,6 @@ def _convert_user_attachments( return signed_attachments -def _compile_worker_ui_hints( - metadata: AgentChatMessageMetadata | dict[str, Any] | None, -) -> dict[str, Any] | None: - """编译 assistant 消息的 agent ui_hints""" - if not metadata: - return None - - if isinstance(metadata, AgentChatMessageMetadata): - agent_output = metadata.agent_output - else: - agent_output_data = metadata.get("agent_output") - if not agent_output_data: - return None - if isinstance(agent_output_data, dict): - raw_ui_schema = agent_output_data.get("ui_schema") - if isinstance(raw_ui_schema, dict): - return raw_ui_schema - from schemas.agent.runtime_models import AgentOutput - - try: - agent_output = AgentOutput.model_validate(agent_output_data) - except Exception: - return None - - if not agent_output: - return None - - ui_hints = agent_output.ui_hints - if not ui_hints: - return None - - try: - compiled = compile_ui_hints(ui_hints) - return compiled - except Exception: - return None - - def mime_to_suffix(mime_type: str) -> str: mapping = { "image/png": "png", diff --git a/backend/src/v1/analytics/__init__.py b/backend/src/v1/analytics/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/v1/analytics/router.py b/backend/src/v1/analytics/router.py deleted file mode 100644 index 69367e5..0000000 --- a/backend/src/v1/analytics/router.py +++ /dev/null @@ -1,174 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import re -import time -from pathlib import Path - -from fastapi import APIRouter, Header, status -from fastapi.responses import PlainTextResponse - -from core.config.settings import config -from core.http.errors import ApiProblemError -from core.logging import get_logger -from v1.analytics.schemas import ( - AnalyticsBatchRequest, - AnalyticsBatchResponse, - AnalyticsLoginRequest, - AnalyticsLoginResponse, -) -from v1.analytics.service import get_analytics_service -from v1.analytics.tasks import write_analytics_events - - -logger = get_logger("v1.analytics.router") - -router = APIRouter(prefix="/analytics", tags=["analytics"]) -_TOKEN_TTL_SECONDS = 300 -_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") - - -def _get_signing_secret() -> bytes: - return config.analytics.password.encode("utf-8") - - -def _issue_access_token() -> str: - expires_at = int(time.time()) + _TOKEN_TTL_SECONDS - payload = {"exp": expires_at} - payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") - signature = hmac.new(_get_signing_secret(), payload_bytes, hashlib.sha256).digest() - return ( - base64.urlsafe_b64encode(payload_bytes).decode("utf-8") - + "." - + base64.urlsafe_b64encode(signature).decode("utf-8") - ) - - -def _parse_bearer_token(authorization: str | None) -> str: - if authorization is None: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_AUTH_HEADER_MISSING", - detail="Missing authorization header", - ) - if not authorization.startswith("Bearer "): - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_AUTH_SCHEME_INVALID", - detail="Invalid authorization scheme", - ) - token = authorization.removeprefix("Bearer ").strip() - if not token: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_AUTH_TOKEN_MISSING", - detail="Missing token", - ) - return token - - -def _verify_access_token(token: str) -> None: - parts = token.split(".") - if len(parts) != 2: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_MALFORMED", - detail="Malformed token", - ) - payload_b64, signature_b64 = parts - try: - payload_bytes = base64.urlsafe_b64decode(payload_b64.encode("utf-8")) - provided_signature = base64.urlsafe_b64decode(signature_b64.encode("utf-8")) - except Exception as exc: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_MALFORMED", - detail="Malformed token", - ) from exc - - expected_signature = hmac.new( - _get_signing_secret(), payload_bytes, hashlib.sha256 - ).digest() - if not hmac.compare_digest(provided_signature, expected_signature): - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_SIGNATURE_INVALID", - detail="Invalid token signature", - ) - - try: - payload = json.loads(payload_bytes) - except json.JSONDecodeError as exc: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_PAYLOAD_INVALID", - detail="Malformed token payload", - ) from exc - - expires_at = payload.get("exp") - if not isinstance(expires_at, int) or int(time.time()) > expires_at: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_EXPIRED", - detail="Token expired", - ) - - -@router.post("/events", response_model=AnalyticsBatchResponse) -async def receive_events(request: AnalyticsBatchRequest) -> AnalyticsBatchResponse: - """接收埋点事件批次""" - service = get_analytics_service() - received = await service.enqueue_events(request) - - events, date = service.get_and_clear_buffer() - if events: - await write_analytics_events(batch=events, date=date) - - return AnalyticsBatchResponse(received=received, queued=True) - - -@router.post("/login", response_model=AnalyticsLoginResponse) -async def login(request: AnalyticsLoginRequest) -> AnalyticsLoginResponse: - """Analytics Dashboard 登录""" - if request.password != config.analytics.password: - logger.warning("Analytics login failed: invalid password") - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_LOGIN_PASSWORD_INVALID", - detail="Invalid password", - ) - - logger.info("Analytics login success") - return AnalyticsLoginResponse( - success=True, - data_base_url="/api/v1/analytics/data", - token=_issue_access_token(), - ) - - -@router.get("/data/{date}", response_class=PlainTextResponse) -async def read_day_events( - date: str, - authorization: str | None = Header(default=None), -) -> PlainTextResponse: - token = _parse_bearer_token(authorization) - _verify_access_token(token) - - if not _DATE_PATTERN.match(date): - raise ApiProblemError( - status_code=status.HTTP_400_BAD_REQUEST, - code="ANALYTICS_DATE_FORMAT_INVALID", - detail="Invalid date format", - ) - - file_path = Path(config.analytics.data_path) / f"{date}.jsonl" - if not file_path.exists() or not file_path.is_file(): - raise ApiProblemError( - status_code=status.HTTP_404_NOT_FOUND, - code="ANALYTICS_FILE_NOT_FOUND", - detail="Analytics file not found", - ) - - content = file_path.read_text(encoding="utf-8") - return PlainTextResponse(content=content, media_type="application/x-ndjson") diff --git a/backend/src/v1/analytics/schemas.py b/backend/src/v1/analytics/schemas.py deleted file mode 100644 index dafa9df..0000000 --- a/backend/src/v1/analytics/schemas.py +++ /dev/null @@ -1,56 +0,0 @@ -from datetime import datetime -from typing import Any, Literal - -from pydantic import BaseModel, Field - - -class AnalyticsContext(BaseModel): - network_type: str | None = None - os_version: str | None = None - device_model: str | None = None - locale: str | None = None - timezone: str | None = None - - -class AnalyticsEvent(BaseModel): - event_id: str - event_type: str - timestamp: datetime - - user_id: str - device_id: str - session_id: str - - platform: Literal["android", "ios", "web"] - app_version: str - app_build: str | None = None - env: Literal["dev", "staging", "prod"] - - page_name: str | None = None - trace_id: str | None = None - request_id: str | None = None - - attributes: dict[str, Any] = Field(default_factory=dict) - metrics: dict[str, int | float] = Field(default_factory=dict) - context: AnalyticsContext | None = None - - -class AnalyticsBatchRequest(BaseModel): - client_time: datetime | None = None - sdk_version: str | None = None - events: list[AnalyticsEvent] - - -class AnalyticsBatchResponse(BaseModel): - received: int - queued: bool = True - - -class AnalyticsLoginRequest(BaseModel): - password: str - - -class AnalyticsLoginResponse(BaseModel): - success: bool - data_base_url: str - token: str diff --git a/backend/src/v1/analytics/service.py b/backend/src/v1/analytics/service.py deleted file mode 100644 index ed30be2..0000000 --- a/backend/src/v1/analytics/service.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime, timezone -from typing import Any - -from core.logging import get_logger -from v1.analytics.schemas import AnalyticsBatchRequest - - -logger = get_logger("v1.analytics.service") - - -class AnalyticsService: - def __init__(self) -> None: - self._buffer: list[dict[str, Any]] = [] - self._buffer_date: str | None = None - - async def enqueue_events(self, request: AnalyticsBatchRequest) -> int: - """接收事件并放入内存缓冲,返回接收数量""" - now = datetime.now(timezone.utc) - received_count = 0 - - for event in request.events: - event_dict = event.model_dump(mode="json") - self._buffer.append(event_dict) - received_count += 1 - - if self._buffer_date is None: - self._buffer_date = now.strftime("%Y-%m-%d") - - logger.info( - "Analytics events received", - count=received_count, - buffer_size=len(self._buffer), - ) - - return received_count - - def get_and_clear_buffer(self) -> tuple[list[dict[str, Any]], str]: - """获取当前缓冲并清空,返回 (events, date)""" - if not self._buffer: - return [], self._buffer_date or datetime.now(timezone.utc).strftime( - "%Y-%m-%d" - ) - - events = self._buffer.copy() - date = self._buffer_date or datetime.now(timezone.utc).strftime("%Y-%m-%d") - - self._buffer.clear() - self._buffer_date = None - - return events, date - - -_analytics_service: AnalyticsService | None = None - - -def get_analytics_service() -> AnalyticsService: - global _analytics_service - if _analytics_service is None: - _analytics_service = AnalyticsService() - return _analytics_service diff --git a/backend/src/v1/analytics/tasks.py b/backend/src/v1/analytics/tasks.py deleted file mode 100644 index c6d1fa8..0000000 --- a/backend/src/v1/analytics/tasks.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -from pathlib import Path - -from core.config.settings import config -from core.logging import get_logger -from core.taskiq.app import worker_general_broker - - -logger = get_logger("v1.analytics.tasks") - - -def _get_analytics_data_path() -> Path: - return Path(config.analytics.data_path) - - -@worker_general_broker.task(task_name="v1.analytics.write_events") -async def write_analytics_events(batch: list[dict], date: str) -> dict: - """批量写入事件到 JSONL 文件""" - data_path = _get_analytics_data_path() - data_path.mkdir(parents=True, exist_ok=True) - - events_by_type: dict[str, list[str]] = {} - - for event_dict in batch: - event_type = event_dict.get("event_type", "unknown") - if event_type not in events_by_type: - events_by_type[event_type] = [] - events_by_type[event_type].append(json.dumps(event_dict, ensure_ascii=False)) - - for event_type, lines in events_by_type.items(): - file_path = data_path / f"{date}.jsonl" - with open(file_path, "a", encoding="utf-8") as f: - for line in lines: - f.write(line + "\n") - - logger.info( - "Analytics events written", - date=date, - total_count=len(batch), - types=list(events_by_type.keys()), - ) - - return {"written": len(batch), "date": date} diff --git a/backend/src/v1/analytics/web/index.html b/backend/src/v1/analytics/web/index.html deleted file mode 100644 index 03f0fdd..0000000 --- a/backend/src/v1/analytics/web/index.html +++ /dev/null @@ -1,466 +0,0 @@ - - - - - - Analytics Dashboard - - - -
-
-

Analytics 登录

-

输入密码进入聚合分析页面

-
-
- - -
-
- -
-

-
-
- - -
- - - - diff --git a/backend/src/v1/auth/dev_phone_session.py b/backend/src/v1/auth/dev_phone_session.py new file mode 100644 index 0000000..5210f04 --- /dev/null +++ b/backend/src/v1/auth/dev_phone_session.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, cast +from uuid import UUID + +import jwt + +from core.config.settings import config +from core.http.errors import ApiProblemError +from core.logging import get_logger +from services.base.supabase import supabase_service +from v1.auth.schemas import ( + AuthUser, + PhoneSessionCreateRequest, + SessionRefreshRequest, + SessionResponse, +) + +logger = get_logger("v1.auth.dev_phone_session") + +_DEV_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 + + +def _auth_error(*, status_code: int, code: str, detail: str) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + +async def create_dev_phone_session( + *, + request: PhoneSessionCreateRequest, +) -> SessionResponse: + user_id = await _find_or_create_user_by_phone(request.phone) + + token = _sign_access_token(sub=str(user_id)) + refresh_token = _sign_refresh_token(sub=str(user_id), phone=request.phone) + return SessionResponse( + access_token=token, + refresh_token=refresh_token, + expires_in=3600, + token_type="bearer", + user=AuthUser(id=str(user_id), phone=request.phone), + ) + + +async def refresh_dev_phone_session( + *, + request: SessionRefreshRequest, +) -> SessionResponse: + claims = _decode_dev_refresh_token(request.refresh_token) + user_id = str(claims.get("sub", "")).strip() + phone = str(claims.get("phone", "")).strip() + if not user_id or not phone: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) + + return SessionResponse( + access_token=_sign_access_token(sub=user_id), + refresh_token=_sign_refresh_token(sub=user_id, phone=phone), + expires_in=3600, + token_type="bearer", + user=AuthUser(id=user_id, phone=phone), + ) + + +async def delete_dev_session(*, refresh_token: str | None) -> None: + if not refresh_token: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_MISSING", + detail="Missing refresh token", + ) + _decode_dev_refresh_token(refresh_token) + + +async def _find_or_create_user_by_phone(phone: str) -> UUID: + admin_client = supabase_service.get_admin_client() + users = await asyncio.to_thread( + _list_users_with_phone, admin_client, phone + ) + if users: + raw_id = str(getattr(users[0], "id", "")) + if raw_id: + return UUID(raw_id) + + return await _create_dev_user(admin_client, phone) + + +async def _create_dev_user(admin_client: Any, phone: str) -> UUID: + try: + user = await asyncio.to_thread( + admin_client.auth.admin.create_user, + { + "phone": phone, + "phone_confirm": True, + }, + ) + raw_id = str(getattr(user, "id", "")) + if not raw_id: + raise _auth_error( + status_code=500, + code="AUTH_USER_CREATE_FAILED", + detail="Failed to create dev user", + ) + logger.info("Created dev user", phone=phone, user_id=raw_id) + return UUID(raw_id) + except Exception as exc: + logger.error("Failed to create dev user", error=str(exc)) + raise _auth_error( + status_code=500, + code="AUTH_USER_CREATE_FAILED", + detail="Failed to create dev user", + ) from exc + + +async def _find_user_id_by_phone(phone: str) -> UUID | None: + admin_client = supabase_service.get_admin_client() + users = await asyncio.to_thread( + _list_users_with_phone, admin_client, phone + ) + if not users: + return None + raw_id = str(getattr(users[0], "id", "")) + return UUID(raw_id) if raw_id else None + + +def _list_users_with_phone(admin_client: Any, phone: str) -> list[Any]: + page = 1 + while page <= 100: + response = admin_client.auth.admin.list_users(page=page, per_page=100) + batch = ( + list(response) + if isinstance(response, list) + else list(getattr(response, "users", [])) + ) + matched = [ + u + for u in batch + if _normalize_phone(getattr(u, "phone", "")) == _normalize_phone(phone) + ] + if matched: + return matched + if len(batch) < 100: + break + page += 1 + return [] + + +def _normalize_phone(raw: object) -> str: + s = str(raw).strip() + for ch in (" ", "-", "(", ")"): + s = s.replace(ch, "") + if s.startswith("+"): + return s + if s.startswith("00") and len(s) > 2: + return f"+{s[2:]}" + if s.isdigit(): + return f"+{s}" + return s + + +def _sign_access_token(*, sub: str) -> str: + secret = config.supabase.jwt_secret + if secret is None: + raise _auth_error( + status_code=500, + code="AUTH_CONFIG_ERROR", + detail="JWT secret not configured", + ) + now = int(time.time()) + payload = { + "sub": sub, + "aud": "authenticated", + "iss": config.supabase.jwt_issuer, + "exp": now + 3600, + "iat": now, + } + return cast( + str, + jwt.encode(payload, secret.get_secret_value(), algorithm=config.supabase.jwt_algorithm), + ) + + +def _sign_refresh_token(*, sub: str, phone: str) -> str: + secret = config.supabase.jwt_secret + if secret is None: + raise _auth_error( + status_code=500, + code="AUTH_CONFIG_ERROR", + detail="JWT secret not configured", + ) + now = int(time.time()) + payload = { + "sub": sub, + "phone": phone, + "aud": "authenticated", + "iss": config.supabase.jwt_issuer, + "exp": now + _DEV_REFRESH_TTL_SECONDS, + "iat": now, + "typ": "refresh", + "env": "dev", + } + return cast( + str, + jwt.encode(payload, secret.get_secret_value(), algorithm=config.supabase.jwt_algorithm), + ) + + +def _decode_dev_refresh_token(token: str) -> dict[str, Any]: + secret = config.supabase.jwt_secret + if secret is None: + raise _auth_error( + status_code=500, + code="AUTH_CONFIG_ERROR", + detail="JWT secret not configured", + ) + try: + payload = cast( + dict[str, Any], + jwt.decode( + token, + secret.get_secret_value(), + algorithms=[config.supabase.jwt_algorithm], + audience="authenticated", + issuer=config.supabase.jwt_issuer, + ), + ) + except jwt.InvalidTokenError as exc: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) from exc + + if payload.get("typ") != "refresh" or payload.get("env") != "dev": + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) + return payload diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index ec41f5e..f0bae9e 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -8,9 +8,15 @@ from pydantic import ValidationError from supabase import AuthError +from core.config.settings import config from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.supabase import supabase_service +from v1.auth.dev_phone_session import ( + create_dev_phone_session, + delete_dev_session, + refresh_dev_phone_session, +) from v1.auth.schemas import ( AuthUser, OtpSendRequest, @@ -50,6 +56,10 @@ class SupabaseAuthGateway(AuthServiceGateway): return supabase_service.get_admin_client() async def send_otp(self, request: OtpSendRequest) -> None: + if config.runtime.environment == "dev": + logger.info("Skipping OTP send in dev environment", phone=request.phone) + return + client = self._get_client() payload: dict[str, Any] = { "phone": request.phone, @@ -75,6 +85,9 @@ class SupabaseAuthGateway(AuthServiceGateway): async def create_phone_session( self, request: PhoneSessionCreateRequest ) -> SessionResponse: + if config.runtime.environment == "dev": + return await create_dev_phone_session(request=request) + client = self._get_client() payload: dict[str, Any] = { "type": "sms", @@ -104,6 +117,9 @@ class SupabaseAuthGateway(AuthServiceGateway): ) from exc async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + if config.runtime.environment == "dev": + return await refresh_dev_phone_session(request=request) + client = self._get_client() try: response = await asyncio.to_thread( @@ -130,6 +146,10 @@ class SupabaseAuthGateway(AuthServiceGateway): ) from exc async def delete_session(self, refresh_token: str | None) -> None: + if config.runtime.environment == "dev": + await delete_dev_session(refresh_token=refresh_token) + return + if not refresh_token: raise _auth_error( status_code=401, diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 2cad943..5efcbf2 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -47,19 +47,20 @@ async def create_phone_session( request: Request, service: AuthService = Depends(get_auth_service), ) -> SessionResponse: - client_ip = _client_ip(request) - await enforce_rate_limit( - scope="phone_session_phone", - identifier=payload.phone, - limit=6, - window_seconds=300, - ) - await enforce_rate_limit( - scope="phone_session_ip", - identifier=client_ip, - limit=20, - window_seconds=300, - ) + if config.runtime.environment != "dev": + client_ip = _client_ip(request) + await enforce_rate_limit( + scope="phone_session_phone", + identifier=payload.phone, + limit=6, + window_seconds=300, + ) + await enforce_rate_limit( + scope="phone_session_ip", + identifier=client_ip, + limit=20, + window_seconds=300, + ) return await service.create_phone_session(payload) diff --git a/backend/src/v1/automation_jobs/service.py b/backend/src/v1/automation_jobs/service.py index 38d2713..b115b3f 100644 --- a/backend/src/v1/automation_jobs/service.py +++ b/backend/src/v1/automation_jobs/service.py @@ -165,7 +165,7 @@ class AutomationJobsService: run_id=run_id, input_text=input_text, runtime_config=RuntimeConfig( - enabled_tools=job.config.enabled_tools or [], + enabled_skills=job.config.enabled_skills or [], context=job.config.context or MessageContextConfig(), ), ) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index d5d363b..12b6497 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -3,7 +3,6 @@ from __future__ import annotations from fastapi import APIRouter from v1.agent.router import router as agent_router -from v1.analytics.router import router as analytics_router from v1.app.router import router as app_router from v1.automation_jobs.router import router as automation_jobs_router from v1.auth.router import router as auth_router @@ -19,7 +18,6 @@ router = APIRouter(prefix="/api/v1") router.include_router(app_router) router.include_router(auth_router) router.include_router(agent_router) -router.include_router(analytics_router) router.include_router(automation_jobs_router) router.include_router(friendships_router) router.include_router(memories_router) diff --git a/backend/tests/integration/test_agentscope_skill_prompt_flow.py b/backend/tests/integration/test_agentscope_skill_prompt_flow.py new file mode 100644 index 0000000..5643c08 --- /dev/null +++ b/backend/tests/integration/test_agentscope_skill_prompt_flow.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import asyncio + +from agentscope.agent import ReActAgent +from agentscope.formatter import DashScopeChatFormatter +from agentscope.memory import InMemoryMemory + +from core.agentscope.tools.toolkit import build_toolkit + + +class _DummyModel: + stream = False + + async def __call__(self, *args, **kwargs): + raise RuntimeError("dummy model should not be called in this test") + + +def test_react_agent_sys_prompt_includes_registered_skill_prompt() -> None: + toolkit = build_toolkit(enabled_skill_names={"calendar", "contacts"}) + agent = ReActAgent( + name="tester", + sys_prompt="base prompt", + model=_DummyModel(), + formatter=DashScopeChatFormatter(), + toolkit=toolkit, + memory=InMemoryMemory(), + ) + + prompt = agent.sys_prompt + assert "base prompt" in prompt + assert "# Agent Skills" in prompt + assert "## calendar" in prompt + assert "## contacts" in prompt + assert "SKILL.md" in prompt + + +def test_view_skill_file_tool_reads_registered_skill_content() -> None: + toolkit = build_toolkit(enabled_skill_names={"calendar"}) + tool = toolkit.tools["view_skill_file"].original_func + + response = asyncio.run( + tool(file_path="calendar/SKILL.md", ranges=[1, 20]), + ) + + assert response.content + block = response.content[0] + text = block["text"] if isinstance(block, dict) else block.text + assert "Calendar Skill" in text or "name: calendar" in text diff --git a/backend/tests/integration/test_cli_skills_live.py b/backend/tests/integration/test_cli_skills_live.py new file mode 100644 index 0000000..de5a18c --- /dev/null +++ b/backend/tests/integration/test_cli_skills_live.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import json +import os +import subprocess +import time +import asyncio +from pathlib import Path +from uuid import uuid4 + +import httpx +import jwt +import pytest + +BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775") + + +def _load_env() -> None: + env_path = Path(__file__).resolve().parents[3] / ".env" + if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +_load_env() + + +def _get_jwt_secret() -> str | None: + return ( + os.getenv("SOCIAL_SUPABASE__JWT_SECRET") + or os.getenv("SUPABASE_JWT_SECRET") + or os.getenv("JWT_SECRET") + ) + + +def _get_supabase_url() -> str: + return ( + os.getenv("SOCIAL_SUPABASE__URL") + or os.getenv("SUPABASE_URL") + or "http://localhost:54321" + ) + + +def _get_test_user_id() -> str | None: + return os.getenv("TEST_USER_ID") + + +def _create_test_jwt(user_id: str) -> str: + jwt_secret = _get_jwt_secret() + if not jwt_secret: + raise RuntimeError("JWT_SECRET not found in environment") + + supabase_url = _get_supabase_url() + + now = int(time.time()) + payload = { + "sub": user_id, + "role": "authenticated", + "aud": "authenticated", + "iss": supabase_url, + "iat": now, + "exp": now + 3600, + } + + return jwt.encode(payload, jwt_secret, algorithm="HS256") + + +async def _get_test_user_token() -> str: + user_id = _get_test_user_id() + if user_id: + return _create_test_jwt(user_id) + + result = subprocess.run( + ["psql", "-t", "-A", "-c", "SELECT id FROM auth.users LIMIT 1;"], + capture_output=True, + text=True, + env={ + **os.environ, + "PGHOST": "localhost", + "PGPORT": "54322", + "PGDATABASE": "postgres", + "PGUSER": "postgres", + "PGPASSWORD": "postgres", + }, + ) + + if result.returncode == 0 and result.stdout.strip(): + user_id = result.stdout.strip() + return _create_test_jwt(user_id) + + pytest.skip("Could not find test user. Set TEST_USER_ID or ensure database is accessible") + + +async def _run_agent_and_collect_events( + client: httpx.AsyncClient, + headers: dict, + thread_id: str, + run_id: str, + user_message: str, + runtime_mode: str = "chat", +) -> tuple[list[dict], bool, str]: + max_attempts = 3 + last_thread_id = thread_id + + for attempt in range(max_attempts): + attempt_run_id = run_id if attempt == 0 else f"{run_id}-retry-{attempt}" + run_resp = await client.post( + f"{BASE_URL}/api/v1/agent/runs", + headers=headers, + json={ + "threadId": thread_id, + "runId": attempt_run_id, + "state": {}, + "messages": [ + { + "id": "u1", + "role": "user", + "content": user_message, + } + ], + "tools": [], + "context": [], + "forwardedProps": {"runtime_mode": runtime_mode}, + }, + ) + if run_resp.status_code != 202: + pytest.fail(f"Run request failed: {run_resp.status_code} - {run_resp.text}") + assert run_resp.status_code == 202 + + run_data = run_resp.json() + effective_thread_id = str(run_data.get("threadId", thread_id)) + effective_run_id = run_data.get("runId", attempt_run_id) + last_thread_id = effective_thread_id + + events_url = f"{BASE_URL}/api/v1/agent/runs/{effective_thread_id}/events?runId={effective_run_id}" + tool_call_results: list[dict] = [] + run_finished = False + run_error_code: str | None = None + + async with client.stream( + "GET", events_url, headers=headers, timeout=120.0 + ) as sse_resp: + if sse_resp.status_code != 200: + error_body = await sse_resp.aread() + pytest.fail( + f"SSE request failed: {sse_resp.status_code} - {error_body.decode()}" + ) + assert sse_resp.status_code == 200 + buffer = "" + async for line in sse_resp.aiter_lines(): + if line.startswith("data:"): + data_str = line.split(":", 1)[1].strip() + if data_str: + buffer = data_str + elif line == "" and buffer: + try: + event_data = json.loads(buffer) + event_type = event_data.get("type") + if event_type == "TOOL_CALL_RESULT": + tool_call_results.append(event_data) + elif event_type == "RUN_ERROR": + run_finished = True + run_error_code = event_data.get("code") + print(f"RUN_ERROR: {event_data}") + break + elif event_type == "RUN_FINISHED": + run_finished = True + break + except json.JSONDecodeError: + pass + buffer = "" + + if run_error_code == "AGENT_UPSTREAM_CONNECTION_ERROR" and attempt < (max_attempts - 1): + await asyncio.sleep(0.4) + continue + + return tool_call_results, run_finished, effective_thread_id + + return [], False, last_thread_id + + +def _check_db_record(table: str, user_id: str, extra_condition: str = "") -> bool: + result = subprocess.run( + [ + "psql", + "-t", + "-A", + "-c", + f"SELECT COUNT(*) FROM {table} WHERE owner_id = '{user_id}'{extra_condition};", + ], + capture_output=True, + text=True, + env={ + **os.environ, + "PGHOST": "localhost", + "PGPORT": "54322", + "PGDATABASE": "postgres", + "PGUSER": "postgres", + "PGPASSWORD": "postgres", + }, + ) + if result.returncode == 0: + count = int(result.stdout.strip() or "0") + return count > 0 + return False + + +@pytest.mark.asyncio +@pytest.mark.live +@pytest.mark.skipif( + os.getenv("CLI_SKILLS_LIVE_TEST") != "1", + reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test", +) +async def test_calendar_create_skill_creates_db_record() -> None: + token = await _get_test_user_token() + user_id = _get_test_user_id() + + async with httpx.AsyncClient(timeout=120.0) as client: + headers = {"Authorization": f"Bearer {token}"} + thread_id = str(uuid4()) + + tomorrow = time.strftime("%Y-%m-%d", time.localtime(time.time() + 86400)) + user_message = ( + f"请帮我创建一个日程测试事件,标题为'CLI集成测试-{thread_id[:8]}'," + f"开始时间是明天{tomorrow}上午10点,持续1小时。" + f"严格按技能说明执行,不要猜测结果。" + ) + + tool_call_results, run_finished, _ = await _run_agent_and_collect_events( + client=client, + headers=headers, + thread_id=thread_id, + run_id="run-calendar-create-test", + user_message=user_message, + ) + + assert run_finished, "Run did not finish" + + project_cli_results = [ + r for r in tool_call_results if r.get("tool_name") == "project_cli" + ] + assert project_cli_results, "No project_cli tool call found" + + cli_result = project_cli_results[0] + assert cli_result.get("status") == "success", f"Tool call failed: {cli_result}" + + args = cli_result.get("tool_call_args", {}) + assert args.get("command") == "calendar" + assert args.get("subcommand") == "create" + + result_payload = cli_result.get("result") + assert isinstance(result_payload, dict), f"Unexpected result payload: {cli_result}" + data_payload = result_payload.get("data") + assert isinstance(data_payload, dict), f"Missing result data payload: {cli_result}" + created_ids = data_payload.get("ids") + assert isinstance(created_ids, list) and created_ids, f"No created event ids returned: {cli_result}" + created_event_id = str(created_ids[0]) + + if user_id and _get_supabase_url().startswith("http://localhost"): + time.sleep(1) + _check_db_record( + "schedule_items", + user_id, + f" AND id = '{created_event_id}'", + ) + + +@pytest.mark.asyncio +@pytest.mark.live +@pytest.mark.skipif( + os.getenv("CLI_SKILLS_LIVE_TEST") != "1", + reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test", +) +async def test_calendar_read_skill_queries_db() -> None: + token = await _get_test_user_token() + + async with httpx.AsyncClient(timeout=120.0) as client: + headers = {"Authorization": f"Bearer {token}"} + thread_id = str(uuid4()) + + user_message = "请查询我今天的日程安排,严格按技能说明执行,不要猜测结果。" + + tool_call_results, run_finished, _ = await _run_agent_and_collect_events( + client=client, + headers=headers, + thread_id=thread_id, + run_id="run-calendar-read-test", + user_message=user_message, + ) + + assert run_finished, "Run did not finish" + + print(f"\n=== Tool call results: {len(tool_call_results)} ===") + for r in tool_call_results: + print(f" - tool_name: {r.get('tool_name')}") + print(f" status: {r.get('status')}") + print(f" tool_call_args: {json.dumps(r.get('tool_call_args', {}), ensure_ascii=False)}") + result = r.get("result") + if isinstance(result, str) and len(result) < 200: + print(f" result: {result}") + elif isinstance(result, dict): + print(f" result keys: {list(result.keys())}") + + project_cli_results = [ + r for r in tool_call_results if r.get("tool_name") == "project_cli" + ] + assert project_cli_results, "No project_cli tool call found" + + cli_result = project_cli_results[0] + assert cli_result.get("status") in {"success", "partial"}, f"Tool call failed: {cli_result}" + + args = cli_result.get("tool_call_args", {}) + assert args.get("command") == "calendar" + assert args.get("subcommand") == "read" + + +@pytest.mark.asyncio +@pytest.mark.live +@pytest.mark.skipif( + os.getenv("CLI_SKILLS_LIVE_TEST") != "1", + reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test", +) +async def test_contacts_read_skill_queries_db() -> None: + token = await _get_test_user_token() + + async with httpx.AsyncClient(timeout=120.0) as client: + headers = {"Authorization": f"Bearer {token}"} + thread_id = str(uuid4()) + + user_message = "请帮我查找我的联系人列表,严格按技能说明执行,不要猜测结果。" + + tool_call_results, run_finished, _ = await _run_agent_and_collect_events( + client=client, + headers=headers, + thread_id=thread_id, + run_id="run-contacts-read-test", + user_message=user_message, + ) + + assert run_finished, "Run did not finish" + + project_cli_results = [ + r for r in tool_call_results if r.get("tool_name") == "project_cli" + ] + assert project_cli_results, "No project_cli tool call found" + + cli_result = project_cli_results[0] + assert cli_result.get("status") in {"success", "partial"}, f"Tool call failed: {cli_result}" + + args = cli_result.get("tool_call_args", {}) + assert args.get("command") == "contacts" + assert args.get("subcommand") == "read" + + +@pytest.mark.asyncio +@pytest.mark.live +@pytest.mark.skipif( + os.getenv("CLI_SKILLS_LIVE_TEST") != "1", + reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test", +) +async def test_memory_update_skill_via_automation() -> None: + token = await _get_test_user_token() + user_id = _get_test_user_id() + + async with httpx.AsyncClient(timeout=120.0) as client: + headers = {"Authorization": f"Bearer {token}"} + thread_id = str(uuid4()) + + user_message = ( + f"请将以下信息写入我的记忆:用户偏好测试字段值为'test-value-{thread_id[:8]}'。" + f"严格按技能说明执行,不要猜测结果。" + ) + + tool_call_results, run_finished, _ = await _run_agent_and_collect_events( + client=client, + headers=headers, + thread_id=thread_id, + run_id="run-memory-update-test", + user_message=user_message, + runtime_mode="automation", + ) + + assert run_finished, "Run did not finish" + + project_cli_results = [ + r for r in tool_call_results if r.get("tool_name") == "project_cli" + ] + assert project_cli_results, "No project_cli tool call found" + + cli_result = project_cli_results[0] + assert cli_result.get("status") in {"success", "partial"}, f"Tool call failed: {cli_result}" + + args = cli_result.get("tool_call_args", {}) + assert args.get("command") == "memory" + assert args.get("subcommand") == "update" + + if user_id: + time.sleep(1) + result = subprocess.run( + [ + "psql", + "-t", + "-A", + "-c", + f"SELECT content FROM memories WHERE owner_id = '{user_id}' AND memory_type = 'user' ORDER BY updated_at DESC LIMIT 1;", + ], + capture_output=True, + text=True, + env={ + **os.environ, + "PGHOST": "localhost", + "PGPORT": "54322", + "PGDATABASE": "postgres", + "PGUSER": "postgres", + "PGPASSWORD": "postgres", + }, + ) + if result.returncode == 0 and result.stdout.strip(): + content = result.stdout.strip() + assert f"test-value-{thread_id[:8]}" in content or "测试" in content diff --git a/backend/tests/integration/test_cli_tool_flow.py b/backend/tests/integration/test_cli_tool_flow.py new file mode 100644 index 0000000..5195c83 --- /dev/null +++ b/backend/tests/integration/test_cli_tool_flow.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from uuid import uuid4 + +import httpx +import jwt +import pytest + +BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775") + + +def _load_env() -> None: + env_path = Path(__file__).resolve().parents[3] / ".env" + if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +_load_env() + + +def _get_jwt_secret() -> str | None: + return ( + os.getenv("SOCIAL_SUPABASE__JWT_SECRET") + or os.getenv("SUPABASE_JWT_SECRET") + or os.getenv("JWT_SECRET") + ) + + +def _get_supabase_url() -> str: + return ( + os.getenv("SOCIAL_SUPABASE__URL") + or os.getenv("SUPABASE_URL") + or "http://localhost:54321" + ) + + +def _get_test_user_id() -> str | None: + return os.getenv("TEST_USER_ID") + + +def _create_test_jwt(user_id: str) -> str: + jwt_secret = _get_jwt_secret() + if not jwt_secret: + raise RuntimeError("JWT_SECRET not found in environment") + + supabase_url = _get_supabase_url() + + now = int(time.time()) + payload = { + "sub": user_id, + "role": "authenticated", + "aud": "authenticated", + "iss": supabase_url, + "iat": now, + "exp": now + 3600, + } + + return jwt.encode(payload, jwt_secret, algorithm="HS256") + + +async def _get_test_user_token() -> str: + user_id = _get_test_user_id() + if user_id: + return _create_test_jwt(user_id) + + import subprocess + result = subprocess.run( + ["psql", "-t", "-A", "-c", "SELECT id FROM auth.users LIMIT 1;"], + capture_output=True, + text=True, + env={**os.environ, "PGHOST": "localhost", "PGPORT": "54322", "PGDATABASE": "postgres", "PGUSER": "postgres", "PGPASSWORD": "postgres"}, + ) + + if result.returncode == 0 and result.stdout.strip(): + user_id = result.stdout.strip() + return _create_test_jwt(user_id) + + pytest.skip("Could not find test user. Set TEST_USER_ID or ensure database is accessible") + + +async def _run_agent_and_collect_events( + client: httpx.AsyncClient, + headers: dict, + thread_id: str, + run_id: str, + user_message: str, +) -> tuple[list[dict], bool]: + run_resp = await client.post( + f"{BASE_URL}/api/v1/agent/runs", + headers=headers, + json={ + "threadId": thread_id, + "runId": run_id, + "state": {}, + "messages": [ + { + "id": "u1", + "role": "user", + "content": user_message, + } + ], + "tools": [], + "context": [], + "forwardedProps": {"runtime_mode": "chat"}, + }, + ) + if run_resp.status_code != 202: + pytest.fail(f"Run request failed: {run_resp.status_code} - {run_resp.text}") + assert run_resp.status_code == 202 + + run_data = run_resp.json() + effective_thread_id = str(run_data.get("threadId", thread_id)) + effective_run_id = run_data.get("runId", run_id) + + events_url = f"{BASE_URL}/api/v1/agent/runs/{effective_thread_id}/events?runId={effective_run_id}" + tool_call_results: list[dict] = [] + run_finished = False + + async with client.stream( + "GET", events_url, headers=headers, timeout=60.0 + ) as sse_resp: + if sse_resp.status_code != 200: + error_body = await sse_resp.aread() + pytest.fail(f"SSE request failed: {sse_resp.status_code} - {error_body.decode()}") + assert sse_resp.status_code == 200 + buffer = "" + async for line in sse_resp.aiter_lines(): + if line.startswith("data:"): + data_str = line.split(":", 1)[1].strip() + if data_str: + buffer = data_str + elif line == "" and buffer: + try: + event_data = json.loads(buffer) + event_type = event_data.get("type") + if event_type == "TOOL_CALL_RESULT": + tool_call_results.append(event_data) + elif event_type in {"RUN_FINISHED", "RUN_ERROR"}: + run_finished = True + break + except json.JSONDecodeError: + pass + buffer = "" + + assert run_finished, "RUN_FINISHED or RUN_ERROR not received" + return tool_call_results, effective_thread_id + + +@pytest.mark.asyncio +@pytest.mark.live +@pytest.mark.skipif( + os.getenv("CLI_TOOL_LIVE_INTEGRATION") != "1", + reason="set CLI_TOOL_LIVE_INTEGRATION=1 to run live CLI tool integration test", +) +async def test_agent_calendar_read_via_cli() -> None: + token = await _get_test_user_token() + + async with httpx.AsyncClient(timeout=60.0) as client: + headers = {"Authorization": f"Bearer {token}"} + thread_id = str(uuid4()) + + tool_call_results, _ = await _run_agent_and_collect_events( + client=client, + headers=headers, + thread_id=thread_id, + run_id="run-cli-calendar-read", + user_message="请查询我今天的日程安排,不要猜测结果,按你的技能说明执行。", + ) + + assert tool_call_results, "expected at least one TOOL_CALL_RESULT event" + tool_names = [result.get("tool_name") for result in tool_call_results] + assert "view_skill_file" in tool_names + assert "project_cli" in tool_names + assert tool_names.index("view_skill_file") < tool_names.index("project_cli") + + view_result = next( + result for result in tool_call_results if result.get("tool_name") == "view_skill_file" + ) + assert view_result.get("status") in {"success", "failure", "partial"} + view_args = view_result.get("tool_call_args") + assert isinstance(view_args, dict) + assert view_args.get("file_path") == "calendar/SKILL.md" + + result = next( + result for result in tool_call_results if result.get("tool_name") == "project_cli" + ) + assert result.get("status") in {"success", "failure", "partial"} + + tool_call_args = result.get("tool_call_args") + assert isinstance(tool_call_args, dict) + assert tool_call_args.get("command") == "calendar" + assert tool_call_args.get("subcommand") == "read" + + raw_result = result.get("result") + if isinstance(raw_result, str): + raw_result = json.loads(raw_result) + assert isinstance(raw_result, dict), f"result should be dict, got {type(raw_result)}" + assert raw_result.get("command") == "calendar" + assert raw_result.get("subcommand") == "read" + + if "ui_schema" in result: + ui_schema = result["ui_schema"] + assert isinstance(ui_schema, dict) + assert "version" in ui_schema + + assert "ui_hints" not in result, "ui_hints should not appear in SSE wire (replaced by ui_schema)" + + +@pytest.mark.asyncio +@pytest.mark.live +@pytest.mark.skipif( + os.getenv("CLI_TOOL_LIVE_INTEGRATION") != "1", + reason="set CLI_TOOL_LIVE_INTEGRATION=1 to run live CLI tool integration test", +) +async def test_tool_ui_schema_in_history() -> None: + token = await _get_test_user_token() + + async with httpx.AsyncClient(timeout=60.0) as client: + headers = {"Authorization": f"Bearer {token}"} + thread_id = str(uuid4()) + + _, effective_thread_id = await _run_agent_and_collect_events( + client=client, + headers=headers, + thread_id=thread_id, + run_id="run-cli-history-test", + user_message="请查询我今天的日程安排,不要猜测结果,按你的技能说明执行。", + ) + + history_resp = await client.get( + f"{BASE_URL}/api/v1/agent/history", + headers=headers, + params={"threadId": effective_thread_id}, + ) + assert history_resp.status_code == 200 + history = history_resp.json() + + assert "scope" in history + assert "messages" in history + + messages = history.get("messages", []) + tool_messages = [ + m + for m in messages + if isinstance(m, dict) and m.get("role") == "tool" + ] + + assert tool_messages, "expected at least one tool message in history" + found_project_cli = False + found_view_skill_file = False + for tool_msg in tool_messages: + metadata = tool_msg.get("metadata", {}) + tool_agent_output = metadata.get("tool_agent_output") + if not tool_agent_output: + continue + tool_name = tool_agent_output.get("tool_name") + assert tool_name in {"project_cli", "view_skill_file"} + assert "result" in tool_agent_output + assert "status" in tool_agent_output + + if tool_name == "view_skill_file": + tool_call_args = tool_agent_output.get("tool_call_args") + assert isinstance(tool_call_args, dict) + assert tool_call_args.get("file_path") == "calendar/SKILL.md" + found_view_skill_file = True + continue + + result = tool_agent_output.get("result") + if isinstance(result, str): + try: + result = json.loads(result) + tool_agent_output["result"] = result + except (json.JSONDecodeError, ValueError): + pass + assert isinstance(result, dict), f"result in DB should be dict, got {type(result)}: {result!r}" + assert result.get("command") == "calendar" + assert result.get("subcommand") == "read" + + ui_hints = tool_agent_output.get("ui_hints") + assert isinstance(ui_hints, dict), f"ui_hints should be dict, got {type(ui_hints)}" + found_project_cli = True + assert found_view_skill_file, "expected persisted view_skill_file tool output" + assert found_project_cli, "expected persisted project_cli tool output" diff --git a/backend/tests/integration/v1/agent/test_sse_flow_live.py b/backend/tests/integration/v1/agent/test_sse_flow_live.py index ca03785..9339cf6 100644 --- a/backend/tests/integration/v1/agent/test_sse_flow_live.py +++ b/backend/tests/integration/v1/agent/test_sse_flow_live.py @@ -1,7 +1,6 @@ from __future__ import annotations import base64 -import os from pathlib import Path from uuid import UUID, uuid4 @@ -9,27 +8,34 @@ import httpx import pytest from sqlalchemy import select +from core.config.settings import config from core.db.session import AsyncSessionLocal from models.agent_chat_message import AgentChatMessage from models.agent_chat_session import AgentChatSession +from schemas.enums import AgentChatMessageRole -BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775") +BASE_URL = f"http://localhost:{5775}" FIXTURE_IMAGE_PATH = ( Path(__file__).resolve().parents[3] / "fixtures" / "images" / "calendar_text_cn.png" ) +def _require_test_phone() -> str: + phone = config.test.phone + if not phone: + pytest.fail("SOCIAL_TEST__PHONE is required for live integration tests") + return phone + + async def _live_access_token(client: httpx.AsyncClient) -> str: - phone = os.getenv("AGENT_LIVE_PHONE") - password = os.getenv("AGENT_LIVE_PASSWORD") - if not phone or not password: - pytest.fail( - "AGENT_LIVE_INTEGRATION=1 requires AGENT_LIVE_PHONE and AGENT_LIVE_PASSWORD" - ) + phone = _require_test_phone() + if not phone.startswith("+"): + phone = f"+{phone}" + code = config.test.code or "000000" response = await client.post( - f"{BASE_URL}/api/v1/auth/sessions", - json={"phone": phone, "password": password}, + f"{BASE_URL}/api/v1/auth/phone-session", + json={"phone": phone, "token": code}, ) response_text = response.text.strip().replace("\n", " ") truncated_text = response_text[:200] @@ -48,8 +54,8 @@ async def _live_access_token(client: httpx.AsyncClient) -> str: @pytest.mark.asyncio @pytest.mark.live async def test_agent_sse_closed_loop_live() -> None: - if os.getenv("AGENT_LIVE_INTEGRATION") != "1": - pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test") + if config.runtime.environment not in {"dev", "test"}: + pytest.skip("live integration tests require dev or test environment") async with httpx.AsyncClient(timeout=30.0) as client: token = await _live_access_token(client) @@ -67,7 +73,7 @@ async def test_agent_sse_closed_loop_live() -> None: ], "tools": [], "context": [], - "forwardedProps": {"agent_type": "worker"}, + "forwardedProps": {"runtime_mode": "chat"}, }, ) assert run_resp.status_code == 202 @@ -110,8 +116,8 @@ async def test_agent_sse_closed_loop_live() -> None: @pytest.mark.asyncio @pytest.mark.live async def test_agent_runs_events_history_live_with_image_input() -> None: - if os.getenv("AGENT_LIVE_INTEGRATION") != "1": - pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test") + if config.runtime.environment not in {"dev", "test"}: + pytest.skip("live integration tests require dev or test environment") image_data = base64.b64encode(FIXTURE_IMAGE_PATH.read_bytes()).decode("ascii") @@ -143,7 +149,7 @@ async def test_agent_runs_events_history_live_with_image_input() -> None: ], "tools": [], "context": [], - "forwardedProps": {"agent_type": "worker"}, + "forwardedProps": {"runtime_mode": "chat"}, }, ) assert run_resp.status_code == 202 @@ -221,3 +227,78 @@ async def test_agent_runs_events_history_live_with_image_input() -> None: assert user_attachments assert isinstance(user_attachments[0], dict) assert isinstance(user_attachments[0].get("path"), str) + + +@pytest.mark.asyncio +@pytest.mark.live +async def test_agent_tool_call_result_persisted_live() -> None: + if config.runtime.environment not in {"dev", "test"}: + pytest.skip("live integration tests require dev or test environment") + + thread_id = str(uuid4()) + + async with httpx.AsyncClient(timeout=30.0) as client: + token = await _live_access_token(client) + headers = {"Authorization": f"Bearer {token}"} + + run_resp = await client.post( + f"{BASE_URL}/api/v1/agent/runs", + headers=headers, + json={ + "threadId": thread_id, + "runId": "run-tool-verify-1", + "state": {}, + "messages": [ + { + "id": "u1", + "role": "user", + "content": "帮我查一下明天有哪些日程安排", + } + ], + "tools": [], + "context": [], + "forwardedProps": {"runtime_mode": "chat"}, + }, + ) + assert run_resp.status_code == 202 + + accepted = run_resp.json() + assert str(accepted["threadId"]) == thread_id + + events_url = f"{BASE_URL}/api/v1/agent/runs/{thread_id}/events?runId=run-tool-verify-1" + event_names: list[str] = [] + async with client.stream( + "GET", events_url, headers=headers, timeout=90.0 + ) as sse_resp: + assert sse_resp.status_code == 200 + async for line in sse_resp.aiter_lines(): + if line.startswith("event:"): + event_name = line.split(":", 1)[1].strip() + event_names.append(event_name) + if event_name in {"RUN_FINISHED", "RUN_ERROR"}: + break + + assert "RUN_STARTED" in event_names, ( + f"missing RUN_STARTED, got: {event_names}" + ) + + finished_ok = "RUN_FINISHED" in event_names + finished_err = "RUN_ERROR" in event_names + assert finished_ok or finished_err, ( + f"no terminal event, got: {event_names}" + ) + + async with AsyncSessionLocal() as session: + rows = await session.execute( + select(AgentChatMessage).where( + AgentChatMessage.session_id == UUID(thread_id), + AgentChatMessage.role == AgentChatMessageRole.TOOL, + ) + ) + tool_messages = list(rows.scalars().all()) + + if finished_ok: + assert len(tool_messages) >= 1, ( + f"expected >=1 role='tool' message but found {len(tool_messages)}. " + f"SSE events: {event_names}" + ) diff --git a/backend/tests/integration/v1/automation_jobs/test_router.py b/backend/tests/integration/v1/automation_jobs/test_router.py index 55725bf..049bffa 100644 --- a/backend/tests/integration/v1/automation_jobs/test_router.py +++ b/backend/tests/integration/v1/automation_jobs/test_router.py @@ -33,13 +33,13 @@ def _make_job_response( status=overrides.get("status", "active"), is_system=overrides.get("is_system", False), config=overrides.get( - "config", - { - "input_template": "Hello", - "enabled_tools": [], - "context": { - "source": "latest_chat", - "window_mode": "day", + "config", + { + "input_template": "Hello", + "enabled_skills": [], + "context": { + "source": "latest_chat", + "window_mode": "day", "window_count": 2, }, "schedule": { @@ -118,7 +118,7 @@ def test_create_automation_job_requires_auth() -> None: "timezone": "Asia/Shanghai", "config": { "input_template": "Hello", - "enabled_tools": [], + "enabled_skills": [], "context": { "source": "latest_chat", "window_mode": "day", @@ -161,7 +161,7 @@ def test_create_automation_job_succeeds() -> None: "status": "active", "config": { "input_template": "Hello", - "enabled_tools": [], + "enabled_skills": [], "context": { "source": "latest_chat", "window_mode": "day", @@ -205,7 +205,7 @@ def test_create_automation_job_respects_limit() -> None: "status": "active", "config": { "input_template": "Hello", - "enabled_tools": [], + "enabled_skills": [], "context": { "source": "latest_chat", "window_mode": "day", diff --git a/backend/tests/unit/core/agentscope/events/test_agui_codec.py b/backend/tests/unit/core/agentscope/events/test_agui_codec.py index 4ab2145..ae9ba40 100644 --- a/backend/tests/unit/core/agentscope/events/test_agui_codec.py +++ b/backend/tests/unit/core/agentscope/events/test_agui_codec.py @@ -63,8 +63,6 @@ def test_text_end_event_with_bare_fields() -> None: "stage": "worker", "status": "success", "answer": "done", - "key_points": ["point1"], - "result_type": "execution_report", "suggested_actions": ["action1"], "ui_schema": {"version": "2.0"}, "inputTokens": 100, @@ -80,8 +78,6 @@ def test_text_end_event_with_bare_fields() -> None: assert result["messageId"] == "assistant-run-1" assert result["status"] == "success" assert result["answer"] == "done" - assert result["key_points"] == ["point1"] - assert result["result_type"] == "execution_report" assert result["suggested_actions"] == ["action1"] assert result["ui_schema"] == {"version": "2.0"} assert "inputTokens" not in result @@ -101,8 +97,6 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None: "stage": "worker", "status": "success", "answer": "done", - "key_points": [], - "result_type": "execution_report", "suggested_actions": [], "inputTokens": 100, "outputTokens": 50, @@ -122,7 +116,7 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None: assert "model" not in result -def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None: +def test_tool_call_result_agui_event_compiles_tool_ui_hints() -> None: event = { "type": "TOOL_CALL_RESULT", "threadId": "thread-1", @@ -140,43 +134,14 @@ def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None: "status": "success", "title": "Done", }, - "ui_schema": {"version": "2.0"}, } result = to_agui_wire_event(event) assert result["type"] == "TOOL_CALL_RESULT" assert "ui_hints" not in result - assert "ui_schema" not in result - - -def test_text_message_end_agui_event_compiles_ui_hints_to_ui_schema() -> None: - event = { - "type": "TEXT_MESSAGE_END", - "threadId": "thread-1", - "runId": "run-1", - "messageId": "assistant-1", - "role": "assistant", - "stage": "worker", - "status": "success", - "answer": "done", - "key_points": [], - "result_type": "summary", - "suggested_actions": [], - "ui_hints": { - "intent": "message", - "status": "info", - "body": "done", - }, - } - - result = to_agui_wire_event(event) - - assert result["type"] == "TEXT_MESSAGE_END" - assert "ui_hints" not in result assert isinstance(result.get("ui_schema"), dict) - def test_step_started_internal_event_keeps_step_name() -> None: internal = { "type": "step.start", diff --git a/backend/tests/unit/core/agentscope/events/test_store.py b/backend/tests/unit/core/agentscope/events/test_store.py index 0a9102d..7256257 100644 --- a/backend/tests/unit/core/agentscope/events/test_store.py +++ b/backend/tests/unit/core/agentscope/events/test_store.py @@ -83,15 +83,8 @@ async def test_store_persists_worker_output_with_answer_as_content( "stage": "worker", "status": "success", "answer": "worker-answer", - "key_points": [], - "result_type": "summary", "suggested_actions": [], "error": None, - "ui_hints": { - "intent": "message", - "status": "success", - "sections": [], - }, } ) @@ -101,7 +94,6 @@ async def test_store_persists_worker_output_with_answer_as_content( metadata = cast(dict[str, Any], append_kwargs["metadata"]) assert sorted(metadata.keys()) == ["agent_output", "agent_type", "run_id"] assert metadata["agent_output"]["answer"] == "worker-answer" - assert metadata["agent_output"]["ui_hints"]["intent"] == "message" assert append_kwargs["cost"] == Decimal("0.123") assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 1)) assert captured["message_delta"] == 1 @@ -165,8 +157,6 @@ async def test_store_sets_history_only_visibility_for_automation_worker_output( "runtime_mode": "automation", "status": "success", "answer": "automation-result", - "key_points": [], - "result_type": "summary", "suggested_actions": [], "error": None, } @@ -194,19 +184,9 @@ async def test_store_persists_router_step_output_for_cost_tracking( "stepName": "router", "_router_persist": { "router_output": { - "normalized_task_input": { - "user_text": "安排明天会议", - "context_summary": "", - }, - "key_entities": [], - "constraints": [], - "task_typing": {"primary": "scheduling"}, - "execution_mode": "tool_assisted", - "result_typing": {"primary": "execution_report"}, - "ui": { - "ui_mode": "none", - "ui_decision_reason": "单任务", - }, + "objective": "安排明天会议", + "context_summary": "", + "requires_tool_evidence": True, }, "response_metadata": { "model": "doubao-seed-1-6-250615", diff --git a/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py b/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py index c4c77c5..7fd785e 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py +++ b/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py @@ -51,7 +51,7 @@ def _run_input() -> RunAgentInput: def _runtime_config() -> RuntimeConfig: return RuntimeConfig( - enabled_tools=[], + enabled_skills=[], context=MessageContextConfig(), ) diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index 971f06a..e2580c3 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -7,13 +7,7 @@ from ag_ui.core import RunAgentInput import core.agentscope.runtime.runner as runner_module from core.agentscope.runtime.runner import AgentScopeRunner from schemas.agent.runtime_models import ( - ExecutionMode, - NormalizedTaskInput, - ResultType, - ResultTyping, RouterAgentOutput, - TaskType, - TaskTyping, WorkerAgentOutputLite, ) from schemas.agent.system_agent import AgentType @@ -46,7 +40,7 @@ def _user_context() -> UserContext: def _runtime_config() -> RuntimeConfig: return RuntimeConfig( - enabled_tools=[], + enabled_skills=[], context=MessageContextConfig(), ) @@ -54,15 +48,9 @@ def _runtime_config() -> RuntimeConfig: def test_build_worker_input_messages_only_contains_router_contract() -> None: runner = AgentScopeRunner() router_output = RouterAgentOutput( - normalized_task_input=NormalizedTaskInput( - user_text="安排明天会议", - context_summary="用户询问天气", - ), - key_entities=[], - constraints=[], - task_typing=TaskTyping(primary=TaskType.SCHEDULING), - execution_mode=ExecutionMode.TOOL_ASSISTED, - result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT), + objective="安排明天会议", + context_summary="用户询问天气", + requires_tool_evidence=True, ) input_messages = runner._build_worker_input_messages(router_output=router_output) @@ -224,15 +212,9 @@ async def test_execute_runs_router_then_worker( async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput: del kwargs return RouterAgentOutput( - normalized_task_input=NormalizedTaskInput( - user_text="安排会议", - context_summary="用户询问天气", - ), - key_entities=[], - constraints=[], - task_typing=TaskTyping(primary=TaskType.SCHEDULING), - execution_mode=ExecutionMode.TOOL_ASSISTED, - result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT), + objective="安排会议", + context_summary="用户询问天气", + requires_tool_evidence=True, ) async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite: @@ -254,7 +236,7 @@ async def test_execute_runs_router_then_worker( ) assert load_calls == [AgentType.ROUTER, AgentType.WORKER] - assert result["router"]["normalized_task_input"]["user_text"] == "安排会议" + assert result["router"]["objective"] == "安排会议" assert result["worker"]["answer"] == "ok" @@ -289,15 +271,9 @@ async def test_execute_raises_cancelled_error_before_worker_when_cancel_requeste async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput: del kwargs return RouterAgentOutput( - normalized_task_input=NormalizedTaskInput( - user_text="安排会议", - context_summary="", - ), - key_entities=[], - constraints=[], - task_typing=TaskTyping(primary=TaskType.SCHEDULING), - execution_mode=ExecutionMode.TOOL_ASSISTED, - result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT), + objective="安排会议", + context_summary="", + requires_tool_evidence=False, ) async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite: diff --git a/backend/tests/unit/core/agentscope/runtime/test_tasks.py b/backend/tests/unit/core/agentscope/runtime/test_tasks.py index 33d3022..a6a8e44 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_tasks.py +++ b/backend/tests/unit/core/agentscope/runtime/test_tasks.py @@ -98,7 +98,7 @@ async def test_run_agentscope_task_calls_runtime_run( "owner_id": str(uuid4()), "run_input": _run_input_payload(), "runtime_config": { - "enabled_tools": [], + "enabled_skills": [], "context": {"window_mode": "day", "window_count": 2}, }, } @@ -154,7 +154,7 @@ async def test_run_agentscope_task_injects_runtime_config( "owner_id": str(uuid4()), "run_input": _run_input_payload(), "runtime_config": { - "enabled_tools": [], + "enabled_skills": [], "context": {"window_mode": "day", "window_count": 2}, }, } @@ -218,7 +218,7 @@ async def test_run_agentscope_task_injects_cancel_checker( "owner_id": str(uuid4()), "run_input": _run_input_payload(), "runtime_config": { - "enabled_tools": [], + "enabled_skills": [], "context": {"window_mode": "day", "window_count": 2}, }, } diff --git a/backend/tests/unit/core/agentscope/schemas/test_tool_agent_output.py b/backend/tests/unit/core/agentscope/schemas/test_tool_agent_output.py new file mode 100644 index 0000000..8e48eb2 --- /dev/null +++ b/backend/tests/unit/core/agentscope/schemas/test_tool_agent_output.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus + + +class TestToolAgentOutputResultCoercion: + def test_dict_result_stays_dict(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.SUCCESS, + result={"total": 0, "items": []}, + ) + assert isinstance(output.result, dict) + assert output.result == {"total": 0, "items": []} + + def test_list_result_stays_list(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.SUCCESS, + result=[{"id": "evt_1"}], + ) + assert isinstance(output.result, list) + + def test_string_result_stays_string(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.SUCCESS, + result="not json", + ) + assert isinstance(output.result, str) + assert output.result == "not json" + + def test_none_result_stays_none(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.FAILURE, + result=None, + error={"code": "ERR", "message": "fail", "retryable": False}, + ) + assert output.result is None + + def test_ui_hints_preserved(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.SUCCESS, + result={"total": 0, "items": []}, + ui_hints={"view": "calendar_event_list", "total": 0}, + ) + assert output.ui_hints == {"view": "calendar_event_list", "total": 0} + + def test_model_dump_includes_ui_hints(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.SUCCESS, + result={"total": 0, "items": []}, + ui_hints={"view": "calendar_event_list"}, + ) + dumped = output.model_dump(mode="json", exclude_none=True) + assert "ui_hints" in dumped + assert dumped["ui_hints"] == {"view": "calendar_event_list"} + assert isinstance(dumped["result"], dict) + + def test_model_dump_excludes_none_ui_hints(self) -> None: + output = ToolAgentOutput( + tool_name="project_cli", + tool_call_id="call-1", + tool_call_args={}, + status=ToolStatus.SUCCESS, + result={"total": 0}, + ui_hints=None, + ) + dumped = output.model_dump(mode="json", exclude_none=True) + assert "ui_hints" not in dumped diff --git a/backend/tests/unit/core/agentscope/test_agent_prompt.py b/backend/tests/unit/core/agentscope/test_agent_prompt.py index ca521bb..73407c9 100644 --- a/backend/tests/unit/core/agentscope/test_agent_prompt.py +++ b/backend/tests/unit/core/agentscope/test_agent_prompt.py @@ -11,7 +11,7 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None: { "temperature": 0.2, "context_messages": {"mode": "number", "count": 20}, - "enabled_tools": ["calendar.read", "calendar.write"], + "enabled_skills": ["calendar", "contacts"], } ), ) @@ -20,10 +20,10 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None: assert "- type: worker" in prompt assert "context_messages.mode=number" in prompt assert "context_messages.count=20" in prompt - assert "enabled_tools=calendar.read,calendar.write" in prompt + assert "enabled_skills=calendar,contacts" in prompt -def test_build_agent_prompt_for_router_contains_task_typing_rules() -> None: +def test_build_agent_prompt_for_router_contains_identity_and_config() -> None: prompt = build_agent_prompt( agent_type=AgentType.ROUTER, llm_config=SystemAgentLLMConfig.model_validate( diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py deleted file mode 100644 index dcc6c2c..0000000 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ /dev/null @@ -1,400 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from datetime import datetime, timezone -from types import SimpleNamespace -from typing import Any, cast -from uuid import UUID, uuid4 - -import pytest -from agentscope.tool import ToolResponse -from core.agentscope.tools.custom import calendar as calendar_module - - -def _decode_tool_response(response: ToolResponse) -> dict[str, Any]: - assert response.content - first = response.content[0] - if isinstance(first, dict): - text = str(first.get("text", "")) - else: - text = str(getattr(first, "text", "")) - return json.loads(text) - - -@dataclass -class _FakeService: - created_request: Any = None - created_id: str = field(default_factory=lambda: str(uuid4())) - list_calls: list[dict[str, Any]] = field(default_factory=list) - range_calls: list[dict[str, Any]] = field(default_factory=list) - deleted_ids: list[str] = field(default_factory=list) - - async def list_paginated( - self, *, page: int, page_size: int, query: str | None = None - ): - self.list_calls.append({"page": page, "page_size": page_size, "query": query}) - item = SimpleNamespace( - id=UUID(self.created_id), - title="会议", - description="今天下午五点的会议", - start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc), - end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc), - timezone="Asia/Shanghai", - status="active", - metadata=SimpleNamespace( - location=None, color="#4F46E5", reminder_minutes=15 - ), - ) - return [item], 1 - - async def list_by_date_range(self, request: Any): - self.range_calls.append( - { - "start_at": request.start_at, - "end_at": request.end_at, - } - ) - return [ - SimpleNamespace( - id=UUID(self.created_id), - owner_id=uuid4(), - title="会议", - description="今天下午五点的会议", - start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc), - end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc), - timezone="Asia/Shanghai", - status="active", - source_type="manual", - metadata=None, - subscribers=[], - ) - ] - - async def create_agent_generated(self, request): - self.created_request = request - return SimpleNamespace( - id=UUID(self.created_id), - title=request.title, - description=request.description, - start_at=request.start_at, - end_at=request.end_at, - timezone=request.timezone, - metadata=request.metadata, - ) - - async def delete(self, item_id: UUID) -> None: - self.deleted_ids.append(str(item_id)) - - async def share(self, item_id: UUID, request: Any) -> None: - if not hasattr(self, "share_calls"): - self.share_calls = [] - self.share_calls.append({"item_id": str(item_id), "request": request}) - - -@pytest.mark.asyncio -async def test_calendar_write_requires_runtime_context() -> None: - result = await calendar_module.calendar_write( - operations=[calendar_module.CalendarWriteOperation(action="create")] - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS" - - -@pytest.mark.asyncio -async def test_calendar_write_create_requires_start_at( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_write( - operations=[ - calendar_module.CalendarWriteOperation( - action="create", - event_timezone="Asia/Shanghai", - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "INVALID_ARGUMENT" - assert "start_at" in payload["error"]["message"] - - -@pytest.mark.asyncio -async def test_calendar_write_create_requires_event_timezone( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_write( - operations=[ - calendar_module.CalendarWriteOperation( - action="create", - start_at="2026-03-16T09:00:00+08:00", - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "INVALID_ARGUMENT" - assert "event_timezone" in payload["error"]["message"] - - -@pytest.mark.asyncio -async def test_calendar_write_rejects_naive_start_at( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_write( - operations=[ - calendar_module.CalendarWriteOperation( - action="create", - start_at="2026-03-16T09:00:00", - event_timezone="Asia/Shanghai", - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "INVALID_ARGUMENT" - assert "时区" in payload["error"]["message"] - - -@pytest.mark.asyncio -async def test_calendar_write_create_normalizes_to_utc( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_write( - operations=[ - calendar_module.CalendarWriteOperation( - action="create", - title="晨会", - start_at="2026-03-16T09:00:00+08:00", - end_at="2026-03-16T10:00:00+08:00", - event_timezone="Asia/Shanghai", - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "success" - assert payload["result"].startswith("status=success") - assert "items=[{status=success,eventId=" in payload["result"] - assert fake_service.created_id in payload["result"] - assert fake_service.created_request is not None - request = fake_service.created_request - assert request.timezone == "Asia/Shanghai" - assert request.start_at == datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc) - assert request.end_at == datetime(2026, 3, 16, 2, 0, tzinfo=timezone.utc) - - -@pytest.mark.asyncio -async def test_calendar_write_batch_supports_create_and_delete( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_write( - operations=[ - calendar_module.CalendarWriteOperation( - action="create", - title="晨会", - start_at="2026-03-16T09:00:00+08:00", - event_timezone="Asia/Shanghai", - ), - calendar_module.CalendarWriteOperation( - action="delete", - event_id=str(uuid4()), - ), - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "success" - assert "success=2" in payload["result"] - assert len(fake_service.deleted_ids) == 1 - - -@pytest.mark.asyncio -async def test_calendar_read_returns_structured_result_with_ids( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_read( - start_at="2026-03-17T00:00:00+08:00", - end_at="2026-03-18T00:00:00+08:00", - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - result_data = json.loads(payload["result"]) - - assert payload["status"] == "success" - assert result_data["total"] == 1 - assert result_data["items"][0]["id"] == fake_service.created_id - assert result_data["items"][0]["timezone"] == "Asia/Shanghai" - assert result_data["items"][0]["description"] == "今天下午五点的会议" - assert result_data["items"][0]["status"] == "active" - assert fake_service.range_calls == [ - { - "start_at": datetime(2026, 3, 16, 16, 0, tzinfo=timezone.utc), - "end_at": datetime(2026, 3, 17, 16, 0, tzinfo=timezone.utc), - } - ] - - -@pytest.mark.asyncio -async def test_calendar_read_rejects_naive_datetime_string( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_read( - start_at="2026-03-17T00:00:00", - end_at="2026-03-18T00:00:00+08:00", - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "INVALID_ARGUMENT" - assert "时区" in payload["error"]["message"] - - -@pytest.mark.asyncio -async def test_calendar_share_executes_with_valid_invitee( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - event_id = str(uuid4()) - result = await calendar_module.calendar_share( - event_id=event_id, - invitees=[ - calendar_module.CalendarShareInvitee( - phone="13900001234", - permissionView=True, - permissionEdit=False, - permissionInvite=False, - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "success" - assert payload["result"].startswith("status=success success=1 failed=0") - assert "+8613900001234" in payload["result"] - assert len(fake_service.share_calls) == 1 - share_call = fake_service.share_calls[0] - assert share_call["item_id"] == event_id - assert share_call["request"].phone == "+8613900001234" - - -@pytest.mark.asyncio -async def test_calendar_share_rejects_invalid_phone( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - - result = await calendar_module.calendar_share( - event_id=str(uuid4()), - invitees=[ - calendar_module.CalendarShareInvitee( - phone="12345", - permissionView=True, - permissionEdit=False, - permissionInvite=False, - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "INVALID_ARGUMENT" - - -@pytest.mark.asyncio -async def test_calendar_share_accepts_json_invitee_payload( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeService() - monkeypatch.setattr( - calendar_module, "create_schedule_service", lambda *_: fake_service - ) - event_id = str(uuid4()) - - result = await calendar_module.calendar_share( - event_id=event_id, - invitees=cast( - Any, - [ - { - "phone": "8613900001234", - "permissionView": True, - "permissionEdit": False, - "permissionInvite": False, - } - ], - ), - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(result) - - assert payload["status"] == "success" - assert payload["result"].startswith("status=success success=1 failed=0") - assert len(fake_service.share_calls) == 1 - share_call = fake_service.share_calls[0] - assert share_call["item_id"] == event_id - assert share_call["request"].phone == "+8613900001234" diff --git a/backend/tests/unit/core/agentscope/test_memory_tools.py b/backend/tests/unit/core/agentscope/test_memory_tools.py deleted file mode 100644 index 4d39e50..0000000 --- a/backend/tests/unit/core/agentscope/test_memory_tools.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import json -from types import SimpleNamespace -from uuid import uuid4 - -import pytest -from agentscope.tool import ToolResponse - -from core.agentscope.tools.custom import memory as memory_module -from models.memories import MemoryType -from schemas.domain.memory_content import UserMemoryContent - - -def _decode_tool_response(response: ToolResponse) -> dict[str, object]: - assert response.content - first = response.content[0] - text = str(first.get("text", "")) if isinstance(first, dict) else str(first.text) - return json.loads(text) - - -def _payload_error_code(payload: dict[str, object]) -> str: - error = payload.get("error") - if not isinstance(error, dict): - return "" - return str(error.get("code") or "") - - -class _FakeMemoriesService: - def __init__(self) -> None: - self.memory: object | None = None - self.updated_user = 0 - self.updated_work = 0 - - async def get_memory_model(self, *, memory_type: MemoryType): - _ = memory_type - return self.memory - - async def update_user_memory(self, **kwargs): - _ = kwargs - self.updated_user += 1 - return SimpleNamespace() - - async def update_work_memory(self, **kwargs): - _ = kwargs - self.updated_work += 1 - return SimpleNamespace() - - -def _user_memory(): - return SimpleNamespace( - id=uuid4(), - owner_id=uuid4(), - memory_type=MemoryType.USER, - content={"preferences": {"communication_style": "简洁"}}, - status="active", - ) - - -@pytest.mark.asyncio -async def test_memory_write_requires_runtime_context() -> None: - response = await memory_module.memory_write( - operations=[ - memory_module.MemoryWriteArgs( - memory_type=MemoryType.USER, - user_content=UserMemoryContent(interests=["跑步"]), - ) - ], - ) - payload = _decode_tool_response(response) - assert payload["status"] == "failure" - assert _payload_error_code(payload) == "MISSING_RUNTIME_ARGS" - - -@pytest.mark.asyncio -async def test_memory_write_updates_user_content( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeMemoriesService() - monkeypatch.setattr( - memory_module, "create_memories_service", lambda **_: fake_service - ) - - response = await memory_module.memory_write( - operations=[ - memory_module.MemoryWriteArgs( - memory_type=MemoryType.USER, - user_content=UserMemoryContent(interests=["阅读"]), - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(response) - - assert payload["status"] == "success" - assert "success=1" in str(payload["result"]) - assert "updated_types=[user]" in str(payload["result"]) - assert fake_service.updated_user == 1 - - -@pytest.mark.asyncio -async def test_memory_forget_updates_content_paths( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeMemoriesService() - fake_service.memory = _user_memory() - monkeypatch.setattr( - memory_module, "create_memories_service", lambda **_: fake_service - ) - - response = await memory_module.memory_forget( - operations=[ - memory_module.MemoryForgetArgs( - memory_type=MemoryType.USER, - forget_paths=["preferences.communication_style"], - ) - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(response) - - assert payload["status"] == "success" - assert "success=1" in str(payload["result"]) - assert "forgotten=1" in str(payload["result"]) - assert fake_service.updated_user == 1 - - -@pytest.mark.asyncio -async def test_memory_write_partial_status_contains_error_details( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_service = _FakeMemoriesService() - call_count = 0 - - async def _update_user_memory(**kwargs): - nonlocal call_count - _ = kwargs - call_count += 1 - if call_count == 2: - raise ValueError("invalid payload") - fake_service.updated_user += 1 - return SimpleNamespace() - - fake_service.update_user_memory = _update_user_memory # type: ignore[method-assign] - monkeypatch.setattr( - memory_module, "create_memories_service", lambda **_: fake_service - ) - - response = await memory_module.memory_write( - operations=[ - memory_module.MemoryWriteArgs( - memory_type=MemoryType.USER, - user_content=UserMemoryContent(interests=["阅读"]), - ), - memory_module.MemoryWriteArgs( - memory_type=MemoryType.USER, - user_content=UserMemoryContent(interests=["跑步"]), - ), - ], - session=SimpleNamespace(), - owner_id=uuid4(), - ) - payload = _decode_tool_response(response) - - assert payload["status"] == "partial" - assert "status=partial" in str(payload["result"]) - assert "failed=1" in str(payload["result"]) - assert _payload_error_code(payload) in {"INVALID_ARGUMENT", "UNKNOWN_ERROR"} diff --git a/backend/tests/unit/core/agentscope/test_route_prompt.py b/backend/tests/unit/core/agentscope/test_route_prompt.py deleted file mode 100644 index bb9124a..0000000 --- a/backend/tests/unit/core/agentscope/test_route_prompt.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from core.agentscope.prompts.route_prompt import ( - build_frontend_route_prompt, - load_frontend_routes_catalog, -) - - -def test_load_frontend_routes_catalog_contains_known_routes() -> None: - catalog = load_frontend_routes_catalog() - - assert catalog.version == "1.0" - route_ids = {route.route_id for route in catalog.routes} - assert "home.main" in route_ids - assert "calendar.event_detail" in route_ids - assert "todo.detail" in route_ids - - -def test_build_frontend_route_prompt_has_guidance_and_routes() -> None: - prompt = build_frontend_route_prompt() - - assert "[Frontend Route Catalog]" in prompt - assert "version=1.0" in prompt - assert "route_id=home.main; path=/home;" in prompt - assert "route_id=calendar.event_detail; path=/calendar/events/{id};" in prompt diff --git a/backend/tests/unit/core/agentscope/test_system_prompt.py b/backend/tests/unit/core/agentscope/test_system_prompt.py index 690a6a6..06caf89 100644 --- a/backend/tests/unit/core/agentscope/test_system_prompt.py +++ b/backend/tests/unit/core/agentscope/test_system_prompt.py @@ -137,22 +137,12 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication agent_type=AgentType.WORKER, user_context=_build_user_context(), now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), - tools=[ - { - "name": "calendar.read", - "description": "读取日程", - "parameters": {"type": "object"}, - } - ], ) assert "[Identity]" in prompt assert "[Runtime Context]" in prompt - assert "" in prompt assert "[Safety Rules]" in prompt - assert "[Frontend Route Catalog]" in prompt assert "[Agent Identity]" in prompt - assert "[Available Tools]" in prompt assert "[Answer Style]" in prompt assert "Default reply language:" not in prompt assert "Follow agent contracts strictly" not in prompt diff --git a/backend/tests/unit/core/agentscope/test_tool_prompt.py b/backend/tests/unit/core/agentscope/test_tool_prompt.py deleted file mode 100644 index f77d7bf..0000000 --- a/backend/tests/unit/core/agentscope/test_tool_prompt.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from core.agentscope.prompts.tool_prompt import build_tools_prompt - - -def test_build_tools_prompt_wraps_section_and_schema() -> None: - prompt = build_tools_prompt( - tools=[ - { - "name": "calendar.read", - "description": "读取日程", - "parameters": { - "type": "object", - "properties": {"page": {"type": "integer"}}, - }, - } - ] - ) - - assert "" in prompt - assert "calendar.read" in prompt - assert '"page":{"type":"integer"}' in prompt diff --git a/backend/tests/unit/core/agentscope/test_tool_result_parsing.py b/backend/tests/unit/core/agentscope/test_tool_result_parsing.py new file mode 100644 index 0000000..2c4b0ae --- /dev/null +++ b/backend/tests/unit/core/agentscope/test_tool_result_parsing.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +from core.agentscope.tools.tool_call_context import store_tool_agent_output, peek_tool_agent_output, consume_tool_agent_output + + +def _load_parsing_module(): + module_name = "test_agentscope_parsing" + module_path = ( + Path(__file__).resolve().parents[4] / "src/core/agentscope/utils/parsing.py" + ) + spec = spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +parsing_module = _load_parsing_module() +parse_tool_agent_output = parsing_module.parse_tool_agent_output +project_tool_result_text = parsing_module.project_tool_result_text + + +def test_project_tool_result_text_returns_json_projection() -> None: + result = { + "status": "success", + "items": [{"id": "evt_1"}], + } + + projected = project_tool_result_text(result) + + assert projected == '{"status":"success","items":[{"id":"evt_1"}]}' + + +def test_parse_tool_agent_output_uses_side_channel_payload() -> None: + tool_call_id = "call-1" + store_tool_agent_output( + tool_call_id=tool_call_id, + payload={ + "tool_name": "calendar.update", + "tool_call_id": tool_call_id, + "tool_call_args": {"title": "Sync"}, + "status": "success", + "result": { + "status": "success", + "event": {"id": "evt_1"}, + }, + "ui_hints": {"view": "calendar_event_created"}, + }, + ) + + output = [{"type": "text", "text": json.dumps({"status": "success"})}] + + parsed = parse_tool_agent_output( + output, + tool_call_id=tool_call_id, + tool_name="calendar.update", + tool_call_args={"title": "Sync"}, + ) + + assert parsed is not None + assert parsed.tool_name == "calendar.update" + assert parsed.tool_call_id == tool_call_id + assert parsed.result == {"status": "success", "event": {"id": "evt_1"}} + assert parsed.ui_hints == {"view": "calendar_event_created"} + + +def test_peek_does_not_consume() -> None: + tool_call_id = "peek-test-1" + store_tool_agent_output( + tool_call_id=tool_call_id, + payload={ + "tool_name": "calendar.read", + "tool_call_id": tool_call_id, + "tool_call_args": {}, + "status": "success", + "result": {"total": 0, "items": []}, + "ui_hints": {"view": "calendar_event_list"}, + }, + ) + + peeked = peek_tool_agent_output(tool_call_id=tool_call_id) + assert peeked is not None + assert peeked["tool_name"] == "calendar.read" + assert peeked["ui_hints"] == {"view": "calendar_event_list"} + + peeked_again = peek_tool_agent_output(tool_call_id=tool_call_id) + assert peeked_again is not None + + consumed = consume_tool_agent_output(tool_call_id=tool_call_id) + assert consumed is not None + + after_consume = peek_tool_agent_output(tool_call_id=tool_call_id) + assert after_consume is None + + +def test_peek_returns_none_for_missing() -> None: + result = peek_tool_agent_output(tool_call_id="nonexistent") + assert result is None diff --git a/backend/tests/unit/core/agentscope/test_toolkit.py b/backend/tests/unit/core/agentscope/test_toolkit.py deleted file mode 100644 index ce6ead1..0000000 --- a/backend/tests/unit/core/agentscope/test_toolkit.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import Any, cast -from uuid import uuid4 - -from core.agentscope.tools.toolkit import build_stage_toolkit -from schemas.agent.system_agent import AgentType - - -def test_build_stage_toolkit_uses_explicit_enabled_tools_as_final_set( - monkeypatch, -) -> None: - captured: dict[str, object] = {} - - def _fake_build_toolkit(**kwargs): - captured.update(kwargs) - return object() - - monkeypatch.setattr( - "core.agentscope.tools.toolkit.build_toolkit", _fake_build_toolkit - ) - - build_stage_toolkit( - agent_type=AgentType.WORKER, - session=cast(Any, object()), - owner_id=uuid4(), - enabled_tool_names={"calendar_read", "user_lookup"}, - ) - - assert captured["enabled_tool_names"] == {"calendar_read", "user_lookup"} diff --git a/backend/tests/unit/core/agentscope/test_toolkit_registry.py b/backend/tests/unit/core/agentscope/test_toolkit_registry.py deleted file mode 100644 index 8d0b88a..0000000 --- a/backend/tests/unit/core/agentscope/test_toolkit_registry.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import cast -from uuid import uuid4 - -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from core.agentscope.tools.toolkit import build_toolkit - - -@pytest.mark.asyncio -async def test_build_toolkit_registers_calendar_tools() -> None: - pytest.importorskip("agentscope") - toolkit = build_toolkit( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - ) - schemas = toolkit.get_json_schemas() - names = {item["function"]["name"] for item in schemas} - assert "calendar_read" in names - assert "calendar_write" in names - assert "calendar_share" in names - assert "memory_write" in names - assert "memory_forget" in names - - write_schema = next( - item for item in schemas if item["function"]["name"] == "calendar_write" - ) - params = write_schema["function"]["parameters"]["properties"] - assert "user_token" not in params - assert "session" not in params - assert "owner_id" not in params diff --git a/backend/tests/unit/core/agentscope/test_user_lookup_tool.py b/backend/tests/unit/core/agentscope/test_user_lookup_tool.py deleted file mode 100644 index 3842d49..0000000 --- a/backend/tests/unit/core/agentscope/test_user_lookup_tool.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import json -from types import SimpleNamespace -from typing import Any -from uuid import uuid4 - -import pytest -from agentscope.tool import ToolResponse -from core.agentscope.tools.custom.user_lookup import user_lookup -import core.agentscope.tools.custom.user_lookup as user_lookup_module - - -def _decode_tool_response(response: ToolResponse) -> dict[str, Any]: - assert response.content - first = response.content[0] - if isinstance(first, dict): - text = str(first.get("text", "")) - else: - text = str(getattr(first, "text", "")) - return json.loads(text) - - -@pytest.mark.asyncio -async def test_user_lookup_requires_runtime_context() -> None: - result = await user_lookup() - payload = _decode_tool_response(result) - - assert payload["status"] == "failure" - assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS" - - -@pytest.mark.asyncio -async def test_user_lookup_returns_friend_contacts( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def _fake_list_friend_contacts(**_: Any) -> list[dict[str, str]]: - return [ - { - "userId": "00000000-0000-0000-0000-000000000101", - "username": "alice", - "phone": "+8613900000001", - }, - { - "userId": "00000000-0000-0000-0000-000000000102", - "username": "bob", - "phone": "+8613900000002", - }, - ] - - monkeypatch.setattr( - user_lookup_module, - "_list_friend_contacts", - _fake_list_friend_contacts, - ) - - result = await user_lookup(session=SimpleNamespace(), owner_id=uuid4()) - payload = _decode_tool_response(result) - - assert payload["status"] == "success" - assert "friends_count=2" in payload["result"] - assert "username=alice" in payload["result"] - assert "+8613900000001" in payload["result"] diff --git a/backend/tests/unit/core/agentscope/tools/test_cli_calendar_handler.py b/backend/tests/unit/core/agentscope/tools/test_cli_calendar_handler.py new file mode 100644 index 0000000..6377456 --- /dev/null +++ b/backend/tests/unit/core/agentscope/tools/test_cli_calendar_handler.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from core.agentscope.tools.cli.handler_calendar import ( + _resolve_read_range, +) +from core.agentscope.tools.cli.models import CliCommand + + +def test_resolve_read_range_supports_date_timezone_fallback() -> None: + request = CliCommand( + command="calendar", + subcommand="read", + owner_id="u1", + args={"date": "2026-04-23", "timezone": "Asia/Shanghai"}, + ) + + start_at, end_at, error = _resolve_read_range(request) + + assert error is None + assert start_at is not None + assert end_at is not None + assert start_at.isoformat() == "2026-04-22T16:00:00+00:00" + assert end_at.isoformat() == "2026-04-23T16:00:00+00:00" + + +def test_resolve_read_range_rejects_bad_date() -> None: + request = CliCommand( + command="calendar", + subcommand="read", + owner_id="u1", + args={"date": "2026/04/23", "timezone": "Asia/Shanghai"}, + ) + + start_at, end_at, error = _resolve_read_range(request) + + assert start_at is None + assert end_at is None + assert error == "date must be YYYY-MM-DD" diff --git a/backend/tests/unit/core/agentscope/tools/test_cli_handlers_router.py b/backend/tests/unit/core/agentscope/tools/test_cli_handlers_router.py new file mode 100644 index 0000000..8fa997b --- /dev/null +++ b/backend/tests/unit/core/agentscope/tools/test_cli_handlers_router.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from core.agentscope.tools.cli.handlers import build_router + + +def test_router_registers_only_new_canonical_subcommands() -> None: + router = build_router() + + assert ("calendar", "create") in router.command_pairs + assert ("calendar", "read") in router.command_pairs + assert ("calendar", "update") in router.command_pairs + assert ("calendar", "delete") in router.command_pairs + assert ("calendar", "share") in router.command_pairs + assert ("contacts", "read") in router.command_pairs + assert ("memory", "update") in router.command_pairs + + assert ("calendar", "write") not in router.command_pairs + assert ("contacts", "lookup") not in router.command_pairs + assert ("memory", "write") not in router.command_pairs + assert ("memory", "forget") not in router.command_pairs diff --git a/backend/tests/unit/core/agentscope/tools/test_cli_router.py b/backend/tests/unit/core/agentscope/tools/test_cli_router.py new file mode 100644 index 0000000..93292f1 --- /dev/null +++ b/backend/tests/unit/core/agentscope/tools/test_cli_router.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from core.agentscope.tools.cli.models import CliCommand, CliCommandResult +from core.agentscope.tools.cli.router import CommandRouter + + +@pytest.mark.asyncio +async def test_router_register_and_dispatch() -> None: + router = CommandRouter() + + async def mock_handler(request: CliCommand) -> CliCommandResult: + return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand, data={"name": request.args["name"]}) + + router.register(command="test", subcommand="run", handler=mock_handler) + + assert ("test", "run") in router.command_pairs + + result = await router.dispatch(CliCommand(command="test", subcommand="run", args={"name": "demo"}, owner_id="u1")) + assert result.ok is True + assert result.data == {"name": "demo"} + + +@pytest.mark.asyncio +async def test_router_unknown_command() -> None: + router = CommandRouter() + result = await router.dispatch(CliCommand(command="unknown", subcommand="run", args={}, owner_id="u1")) + assert result.ok is False + assert result.error is not None + assert result.error.code == "UNKNOWN_COMMAND" + + +@pytest.mark.asyncio +async def test_router_handler_exception() -> None: + router = CommandRouter() + + async def failing_handler(request: CliCommand) -> CliCommandResult: + del request + raise ValueError("intentional error") + + router.register(command="fail", subcommand="run", handler=failing_handler) + + result = await router.dispatch(CliCommand(command="fail", subcommand="run", args={}, owner_id="u1")) + assert result.ok is False + assert result.error is not None + assert result.error.code == "HANDLER_ERROR" + + +def test_router_duplicate_register() -> None: + router = CommandRouter() + + async def handler1(request: CliCommand) -> CliCommandResult: + return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand) + + async def handler2(request: CliCommand) -> CliCommandResult: + return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand) + + router.register(command="cmd", subcommand="one", handler=handler1) + + with pytest.raises(ValueError, match="already registered"): + router.register(command="cmd", subcommand="one", handler=handler2) diff --git a/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py b/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py new file mode 100644 index 0000000..2356359 --- /dev/null +++ b/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from core.agentscope.tools.tool_postprocessor import postprocess_tool_output +from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus + + +def _make_tool_output( + *, + command: str, + subcommand: str, + status: ToolStatus, + data: dict | None = None, +) -> ToolAgentOutput: + return ToolAgentOutput( + tool_name="project_cli", + tool_call_id="test_call_id", + tool_call_args={"command": command, "subcommand": subcommand, "args": {}}, + status=status, + result={"command": command, "subcommand": subcommand, "data": data or {}}, + error=None, + ui_hints=None, + ) + + +def test_postprocess_calendar_read_has_ui_hints() -> None: + output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5, "items": []}) + processed = postprocess_tool_output(output) + assert processed.ui_hints is not None + assert processed.ui_hints["intent"] == "list" + + +def test_postprocess_calendar_create_partial() -> None: + output = _make_tool_output(command="calendar", subcommand="create", status=ToolStatus.PARTIAL, data={"status": "partial", "success": 1, "failed": 1, "results": []}) + processed = postprocess_tool_output(output) + assert processed.ui_hints is not None + assert processed.ui_hints["intent"] == "status" + assert processed.ui_hints["status"] == "warning" + + +def test_postprocess_calendar_share_has_ui_hints() -> None: + output = _make_tool_output( + command="calendar", + subcommand="share", + status=ToolStatus.SUCCESS, + data={ + "status": "success", + "success": 2, + "failed": 0, + "results": [ + {"phone": "+8613800138001", "status": "success"}, + {"phone": "+8613800138002", "status": "success"}, + ], + }, + ) + processed = postprocess_tool_output(output) + assert processed.ui_hints is not None + assert processed.ui_hints["intent"] == "status" + assert processed.ui_hints["status"] == "success" + assert processed.ui_hints["title"] == "日程分享结果" + + +def test_postprocess_contacts_read_has_ui_hints() -> None: + output = _make_tool_output(command="contacts", subcommand="read", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []}) + processed = postprocess_tool_output(output) + assert processed.ui_hints is not None + assert processed.ui_hints["intent"] == "list" + assert processed.ui_hints["status"] == "success" + + +def test_postprocess_memory_update_has_ui_hints() -> None: + output = _make_tool_output( + command="memory", + subcommand="update", + status=ToolStatus.SUCCESS, + data={ + "status": "success", + "success": 1, + "failed": 0, + "forgotten": 5, + "results": [ + { + "memoryType": "user", + "action": "delete", + "status": "success", + "forgotten": 5, + "memoryId": "mem_1", + } + ], + }, + ) + processed = postprocess_tool_output(output) + assert processed.ui_hints is not None + assert processed.ui_hints["intent"] == "status" + assert processed.ui_hints["status"] == "success" + + +def test_postprocess_failure_no_ui_hints() -> None: + output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.FAILURE, data=None) + processed = postprocess_tool_output(output) + assert processed.ui_hints is None + + +def test_postprocess_unknown_command_no_ui_hints() -> None: + output = _make_tool_output(command="unknown", subcommand="run", status=ToolStatus.SUCCESS, data={"data": "test"}) + processed = postprocess_tool_output(output) + assert processed.ui_hints is None + + +def test_postprocess_preserves_existing_ui_hints() -> None: + output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5}) + output = output.model_copy(update={"ui_hints": {"view": "custom_view", "custom": True}}) + processed = postprocess_tool_output(output) + assert processed.ui_hints["view"] == "custom_view" + assert processed.ui_hints["custom"] is True diff --git a/backend/tests/unit/core/agentscope/tools/test_toolkit.py b/backend/tests/unit/core/agentscope/tools/test_toolkit.py new file mode 100644 index 0000000..46c048e --- /dev/null +++ b/backend/tests/unit/core/agentscope/tools/test_toolkit.py @@ -0,0 +1,71 @@ +import asyncio + +from core.agentscope.tools.internal.project_cli import PROJECT_CLI_TOOL_NAME +from core.agentscope.tools.internal.view_skill_file import VIEW_SKILL_FILE_TOOL_NAME +from core.agentscope.tools.internal import make_view_skill_file_wrapper +from core.agentscope.tools.toolkit import build_toolkit +from schemas.agent.skill_config import SkillName + + +def test_skill_names_is_complete() -> None: + expected = {"calendar", "contacts", "memory"} + assert {skill.value for skill in SkillName} == expected + + +def test_validate_rejects_unknown_skill() -> None: + from core.agentscope.tools.toolkit import _validate_enabled_skill_names + + try: + _validate_enabled_skill_names({"calendar", "nonexistent"}) + assert False, "should have raised" + except ValueError as exc: + assert "nonexistent" in str(exc) + + +def test_validate_accepts_known_skills() -> None: + from core.agentscope.tools.toolkit import _validate_enabled_skill_names + + result = _validate_enabled_skill_names({"calendar", "contacts"}) + assert result == {"calendar", "contacts"} + + +def test_validate_rejects_unknown_allowed_command() -> None: + from core.agentscope.tools.toolkit import _validate_allowed_commands + + try: + _validate_allowed_commands({"calendar", "unknown_command"}) + assert False, "should have raised" + except ValueError as exc: + assert "unknown_command" in str(exc) + + +def test_build_toolkit_registers_project_cli() -> None: + toolkit = build_toolkit() + schemas = toolkit.get_json_schemas() + assert {item["function"]["name"] for item in schemas} == { + PROJECT_CLI_TOOL_NAME, + VIEW_SKILL_FILE_TOOL_NAME, + } + + +def test_view_skill_file_rejects_path_outside_enabled_skill_dirs() -> None: + wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"}) + + response = asyncio.run( + wrapper(file_path="/tmp/not-allowed.txt", ranges=None), + ) + + assert response.content + block = response.content[0] + text = block["text"] if isinstance(block, dict) else block.text + assert "ACCESS_DENIED" in text or "access denied" in text.lower() + + +def test_view_skill_file_reads_enabled_skill_file() -> None: + wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"}) + response = asyncio.run(wrapper(file_path="calendar/SKILL.md", ranges=[1, 10])) + + assert response.content + block = response.content[0] + text = block["text"] if isinstance(block, dict) else block.text + assert "Calendar Skill" in text or "name: calendar" in text diff --git a/backend/tests/unit/core/auth/test_credential_issuer.py b/backend/tests/unit/core/auth/test_credential_issuer.py new file mode 100644 index 0000000..3f7c49a --- /dev/null +++ b/backend/tests/unit/core/auth/test_credential_issuer.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import time +from uuid import uuid4 + +import jwt +import pytest + +from core.auth.credential_issuer import ToolCredentialIssuer +from core.auth.jwt_verifier import TokenValidationError + + +_ISSUER = "https://example.com/auth/v1" +_SECRET = "test-secret-key-for-testing-only" +_ALGORITHM = "HS256" +_TTL = 600 + + +def _make_issuer(**overrides) -> ToolCredentialIssuer: + kwargs = { + "jwt_secret": _SECRET, + "jwt_algorithm": _ALGORITHM, + "jwt_issuer": _ISSUER, + "ttl_seconds": _TTL, + } + kwargs.update(overrides) + return ToolCredentialIssuer(**kwargs) + + +class TestToolCredentialIssuerIssue: + def test_issue_returns_valid_jwt(self) -> None: + issuer = _make_issuer() + owner_id = str(uuid4()) + token = issuer.issue(owner_id=owner_id, mode="chat") + assert isinstance(token, str) + payload = jwt.decode( + token, + _SECRET, + algorithms=[_ALGORITHM], + audience="agent-tool-runtime", + ) + assert payload["sub"] == owner_id + assert payload["aud"] == "agent-tool-runtime" + assert payload["iss"] == _ISSUER + assert payload["purpose"] == "agent_tool_runtime" + assert payload["mode"] == "chat" + assert "exp" in payload + assert "iat" in payload + + def test_issue_defaults_mode_to_chat(self) -> None: + issuer = _make_issuer() + token = issuer.issue(owner_id=str(uuid4())) + payload = jwt.decode(token, _SECRET, algorithms=[_ALGORITHM], audience="agent-tool-runtime") + assert payload["mode"] == "chat" + + def test_issue_automation_mode(self) -> None: + issuer = _make_issuer() + token = issuer.issue(owner_id=str(uuid4()), mode="automation") + payload = jwt.decode(token, _SECRET, algorithms=[_ALGORITHM], audience="agent-tool-runtime") + assert payload["mode"] == "automation" + + def test_issue_rejects_unsupported_algorithm(self) -> None: + with pytest.raises(TokenValidationError, match="Unsupported"): + _make_issuer(jwt_algorithm="RS256") + + +class TestToolCredentialIssuerVerify: + def test_verify_returns_claims_for_valid_token(self) -> None: + issuer = _make_issuer() + owner_id = str(uuid4()) + token = issuer.issue(owner_id=owner_id, mode="chat") + claims = issuer.verify(token) + assert claims["sub"] == owner_id + assert claims["purpose"] == "agent_tool_runtime" + + def test_verify_rejects_expired_token(self) -> None: + issuer = _make_issuer(ttl_seconds=0) + token = issuer.issue(owner_id=str(uuid4())) + time.sleep(1) + with pytest.raises(TokenValidationError, match="expired"): + issuer.verify(token) + + def test_verify_rejects_wrong_secret(self) -> None: + issuer = _make_issuer() + token = issuer.issue(owner_id=str(uuid4())) + wrong_issuer = _make_issuer(jwt_secret="wrong-secret") + with pytest.raises(TokenValidationError, match="signature"): + wrong_issuer.verify(token) + + def test_verify_rejects_wrong_audience(self) -> None: + payload = { + "sub": str(uuid4()), + "aud": "wrong-audience", + "iss": _ISSUER, + "purpose": "agent_tool_runtime", + "exp": int(time.time()) + 600, + "iat": int(time.time()), + } + token = jwt.encode(payload, _SECRET, algorithm=_ALGORITHM) + issuer = _make_issuer() + with pytest.raises(TokenValidationError): + issuer.verify(token) + + def test_verify_rejects_wrong_purpose(self) -> None: + payload = { + "sub": str(uuid4()), + "aud": "agent-tool-runtime", + "iss": _ISSUER, + "purpose": "wrong_purpose", + "exp": int(time.time()) + 600, + "iat": int(time.time()), + } + token = jwt.encode(payload, _SECRET, algorithm=_ALGORITHM) + issuer = _make_issuer() + with pytest.raises(TokenValidationError, match="purpose"): + issuer.verify(token) + + def test_verify_rejects_wrong_issuer(self) -> None: + issuer_a = _make_issuer() + token = issuer_a.issue(owner_id=str(uuid4())) + issuer_b = _make_issuer(jwt_issuer="https://other.example.com/auth/v1") + with pytest.raises(TokenValidationError, match="issuer"): + issuer_b.verify(token) + + def test_verify_rejects_malformed_token(self) -> None: + issuer = _make_issuer() + with pytest.raises(TokenValidationError, match="decode"): + issuer.verify("not-a-valid-jwt") + + +class TestToolCredentialContext: + def test_set_and_get_credential(self) -> None: + from core.auth.tool_credential_context import ( + set_tool_credential, + reset_tool_credential, + get_tool_credential, + ) + + assert get_tool_credential() is None + token = set_tool_credential("test-credential") + assert get_tool_credential() == "test-credential" + reset_tool_credential(token) + assert get_tool_credential() is None diff --git a/backend/tests/unit/core/automation/test_scheduler.py b/backend/tests/unit/core/automation/test_scheduler.py index 365a7a7..30d7ed7 100644 --- a/backend/tests/unit/core/automation/test_scheduler.py +++ b/backend/tests/unit/core/automation/test_scheduler.py @@ -60,7 +60,7 @@ def _make_orm_job( owner_id=owner_id or uuid4(), title="Test Job", config={ - "enabled_tools": ["calendar.read", "user.lookup"], + "enabled_skills": ["calendar", "contacts"], "context": { "source": "latest_chat", "window_mode": "day", @@ -109,7 +109,7 @@ async def test_scan_and_dispatch_calls_dispatch_fn_with_runtime_config() -> None assert dispatched_calls[0]["owner_id"] == owner_id assert dispatched_calls[0]["runtime_config"] is not None cfg: RuntimeConfig = dispatched_calls[0]["runtime_config"] - assert len(cfg.enabled_tools) == 2 + assert len(cfg.enabled_skills) == 2 def test_compute_next_run_at_daily() -> None: diff --git a/backend/tests/unit/core/config/test_memory_automation_static_config.py b/backend/tests/unit/core/config/test_memory_automation_static_config.py index 8686162..1a98747 100644 --- a/backend/tests/unit/core/config/test_memory_automation_static_config.py +++ b/backend/tests/unit/core/config/test_memory_automation_static_config.py @@ -8,10 +8,7 @@ def test_memory_automation_static_config_contract() -> None: assert config.context.window_mode.value == "day" assert config.context.window_count == 2 - assert [tool.value for tool in config.enabled_tools] == [ - "memory.write", - "memory.forget", - ] + assert [skill.value for skill in config.enabled_skills] == ["memory"] assert config.input_template is not None assert "回顾" in config.input_template assert "遗忘" in config.input_template diff --git a/backend/tests/unit/schemas/agent/test_runtime_models.py b/backend/tests/unit/schemas/agent/test_runtime_models.py index b26f267..4a8c285 100644 --- a/backend/tests/unit/schemas/agent/test_runtime_models.py +++ b/backend/tests/unit/schemas/agent/test_runtime_models.py @@ -1,90 +1,28 @@ from __future__ import annotations -from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputRich +from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputLite -def test_router_agent_output_coerces_key_entity_value_to_string() -> None: +def test_router_agent_output_parses_simplified_contract() -> None: payload = { - "normalized_task_input": { - "user_text": "test", - "multimodal_summary": [], - "context_summary": "", - }, - "key_entities": [ - { - "name": "priority", - "type": "number", - "value": 8, - } - ], - "constraints": [], - "task_typing": { - "primary": "planning", - "secondary": [], - }, - "execution_mode": "onestep", - "result_typing": { - "primary": "summary", - "secondary": [], - }, + "objective": "查询今天的日程安排", + "context_summary": "用户询问天气", + "requires_tool_evidence": True, } model = RouterAgentOutput.model_validate(payload) - assert model.key_entities[0].value == "8" + assert model.objective == "查询今天的日程安排" + assert model.requires_tool_evidence is True -def test_router_agent_output_coerces_constraint_value_to_string() -> None: - payload = { - "normalized_task_input": { - "user_text": "test", - "multimodal_summary": [], - "context_summary": "", - }, - "key_entities": [], - "constraints": [ - { - "key": "strict_mode", - "value": True, - "required": True, - } - ], - "task_typing": { - "primary": "planning", - "secondary": [], - }, - "execution_mode": "onestep", - "result_typing": { - "primary": "summary", - "secondary": [], - }, - } - - model = RouterAgentOutput.model_validate(payload) - - assert model.constraints[0].value == "True" - - -def test_worker_agent_output_rich_accepts_list_item_status_object() -> None: +def test_worker_agent_output_lite_keeps_suggested_actions() -> None: payload = { "status": "success", "answer": "done", - "result_type": "summary", - "ui_hints": { - "intent": "status", - "status": "info", - "title": "状态", - "listItems": [ - { - "title": "任务A", - "status": {"type": "info", "value": "已归档"}, - } - ], - }, + "suggested_actions": ["要不要我继续帮你查明天的安排?"], } - model = WorkerAgentOutputRich.model_validate(payload) + model = WorkerAgentOutputLite.model_validate(payload) - assert model.ui_hints is not None - assert model.ui_hints.list_items[0].status is not None - assert model.ui_hints.list_items[0].status.value == "info" + assert model.suggested_actions == ["要不要我继续帮你查明天的安排?"] diff --git a/backend/tests/unit/schemas/agent/test_system_agent.py b/backend/tests/unit/schemas/agent/test_system_agent.py index 5029d77..725a941 100644 --- a/backend/tests/unit/schemas/agent/test_system_agent.py +++ b/backend/tests/unit/schemas/agent/test_system_agent.py @@ -5,27 +5,20 @@ import pytest from schemas.agent.system_agent import SystemAgentLLMConfig -def test_system_agent_llm_config_normalizes_enabled_tools_aliases() -> None: +def test_system_agent_llm_config_normalizes_enabled_skills() -> None: config = SystemAgentLLMConfig.model_validate( { - "enabled_tools": [ - "calendar.write", - "calendar_write", - "user.lookup", - ] + "enabled_skills": ["calendar", "calendar", "contacts"] } ) - assert [tool.value for tool in config.enabled_tools] == [ - "calendar.write", - "user.lookup", - ] + assert [skill.value for skill in config.enabled_skills] == ["calendar", "contacts"] -def test_system_agent_llm_config_rejects_unknown_enabled_tool() -> None: - with pytest.raises(ValueError, match="unknown enabled tool"): +def test_system_agent_llm_config_rejects_unknown_enabled_skill() -> None: + with pytest.raises(ValueError): SystemAgentLLMConfig.model_validate( { - "enabled_tools": ["calendar.remove"], + "enabled_skills": ["calendar.remove"], } ) diff --git a/backend/tests/unit/schemas/automation/test_config.py b/backend/tests/unit/schemas/automation/test_config.py index bc8b85f..36b415e 100644 --- a/backend/tests/unit/schemas/automation/test_config.py +++ b/backend/tests/unit/schemas/automation/test_config.py @@ -9,8 +9,7 @@ from v1.auth.automation_static_config import load_static_automation_job_config def test_memory_extraction_static_config_has_expected_defaults() -> None: config = load_static_automation_job_config(config_name="memory_extraction") - assert "memory.write" in (config.enabled_tools or []) - assert "memory.forget" in (config.enabled_tools or []) + assert [skill.value for skill in (config.enabled_skills or [])] == ["memory"] assert config.context is not None assert config.context.source.value == "latest_chat" assert config.schedule is not None @@ -21,7 +20,7 @@ def test_automation_job_config_rejects_missing_weekdays_for_weekly() -> None: with pytest.raises(ValueError, match="weekdays is required"): AutomationJobConfig.model_validate( { - "enabled_tools": ["calendar.read"], + "enabled_skills": ["calendar"], "input_template": "x", "context": { "source": "latest_chat", diff --git a/backend/tests/unit/v1/agent/test_attachment_storage.py b/backend/tests/unit/v1/agent/test_attachment_storage.py index a8ebb8b..bec46a8 100644 --- a/backend/tests/unit/v1/agent/test_attachment_storage.py +++ b/backend/tests/unit/v1/agent/test_attachment_storage.py @@ -38,12 +38,12 @@ async def test_attachment_storage_rejects_unexpected_bucket( storage = SupabaseService() monkeypatch.setattr( - app_config.storage, + app_config.storage.attachment, "bucket", "allowed-bucket", ) - with pytest.raises(RuntimeError, match="Invalid attachment bucket"): + with pytest.raises(RuntimeError, match="Invalid storage bucket"): await storage.upload_bytes( bucket="other-bucket", path="agent-inputs/u/t/r/file.png", @@ -62,7 +62,7 @@ async def test_attachment_storage_accepts_configured_bucket( fake_bucket = _FakeBucket() fake_client = SimpleNamespace(storage=_FakeStorage(fake_bucket)) monkeypatch.setattr( - app_config.storage, + app_config.storage.attachment, "bucket", "allowed-bucket", ) diff --git a/backend/tests/unit/v1/agent/test_owner_guard.py b/backend/tests/unit/v1/agent/test_owner_guard.py index b1d994c..d127b8b 100644 --- a/backend/tests/unit/v1/agent/test_owner_guard.py +++ b/backend/tests/unit/v1/agent/test_owner_guard.py @@ -3,7 +3,7 @@ from __future__ import annotations from uuid import uuid4 import pytest -from fastapi import HTTPException +from core.http.errors import ApiProblemError from core.auth.models import CurrentUser from v1.agent.service import ensure_session_owner @@ -12,5 +12,5 @@ from v1.agent.service import ensure_session_owner def test_owner_guard_denies_non_owner() -> None: user = CurrentUser(id=uuid4(), phone="self@example.com") - with pytest.raises(HTTPException): + with pytest.raises(ApiProblemError): ensure_session_owner(owner_id="other-user", current_user=user) diff --git a/backend/tests/unit/v1/agent/test_service.py b/backend/tests/unit/v1/agent/test_service.py index d4ffdab..e4a5ca2 100644 --- a/backend/tests/unit/v1/agent/test_service.py +++ b/backend/tests/unit/v1/agent/test_service.py @@ -6,10 +6,10 @@ from urllib.parse import quote from uuid import UUID from ag_ui.core import RunAgentInput -from fastapi import HTTPException import pytest import v1.agent.service as agent_service_module +from core.http.errors import ApiProblemError from core.auth.models import CurrentUser from core.config.settings import config from schemas.domain.chat_message import AgentChatMessageMetadata @@ -25,7 +25,7 @@ class _FakeRepository: async def get_session_owner(self, *, session_id: str) -> str: if session_id == "00000000-0000-0000-0000-000000000001": return "00000000-0000-0000-0000-000000000001" - raise HTTPException(status_code=404, detail="Session not found") + raise ApiProblemError(status_code=404, detail="Session not found") async def create_session_for_user( self, *, user_id: str, session_id: str | None = None @@ -92,7 +92,7 @@ class _FakeRepository: "timeout_seconds": 30, "visibility_consumer_bit": bit, "context_messages": {"mode": "number", "count": 20}, - "enabled_tools": [], + "enabled_skills": [], }, } @@ -201,7 +201,7 @@ def _build_run_input(*, urls: list[str], runtime_mode: str = "chat") -> RunAgent @pytest.mark.asyncio async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) service = AgentService( repository=_FakeRepository(), @@ -215,11 +215,11 @@ async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> N ] ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.enqueue_run(run_input=run_input, current_user=_user()) assert exc_info.value.status_code == 422 - assert exc_info.value.detail == "INVALID_BINARY_URL_HOST" + assert exc_info.value.detail == "Invalid binary url host" @pytest.mark.asyncio @@ -227,7 +227,7 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token( monkeypatch, ) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) repository = _FakeRepository() queue = _FakeQueue() @@ -274,7 +274,7 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token( @pytest.mark.asyncio async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) service = AgentService( repository=_FakeRepository(), @@ -294,7 +294,7 @@ async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None: runtime_mode="planner", ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.enqueue_run(run_input=run_input, current_user=_user()) assert exc_info.value.status_code == 422 @@ -303,7 +303,7 @@ async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None: @pytest.mark.asyncio async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) repository = _FakeRepository() service = AgentService( @@ -314,7 +314,7 @@ async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None: ) run_input = _build_run_input(urls=[], runtime_mode="planner") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.enqueue_run(run_input=run_input, current_user=_user()) assert exc_info.value.status_code == 422 @@ -324,7 +324,7 @@ async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None: @pytest.mark.asyncio async def test_create_attachment_signed_url_returns_url(monkeypatch) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) service = AgentService( repository=_FakeRepository(), @@ -349,7 +349,7 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path( monkeypatch, ) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) service = AgentService( repository=_FakeRepository(), @@ -358,7 +358,7 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path( attachment_storage=_FakeAttachmentStorage(), ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.create_attachment_signed_url( bucket="agent-test-bucket", path="agent-inputs/other-user/thread-x/uploads/a.png", @@ -371,7 +371,7 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path( @pytest.mark.asyncio async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None: monkeypatch.setattr( - agent_service_module.config.storage, "bucket", "agent-test-bucket" + agent_service_module.config.storage.attachment, "bucket", "agent-test-bucket" ) service = AgentService( repository=_FakeRepository(), @@ -405,7 +405,7 @@ async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None: ] ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.enqueue_run(run_input=run_input, current_user=_user()) assert exc_info.value.status_code == 422 @@ -413,7 +413,7 @@ async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None: @pytest.mark.asyncio -async def test_get_history_snapshot_filters_out_tool_messages() -> None: +async def test_get_history_snapshot_keeps_tool_messages_for_ui_replay() -> None: class _HistoryRepository(_FakeRepository): async def get_history_day( self, @@ -446,7 +446,20 @@ async def test_get_history_snapshot_filters_out_tool_messages() -> None: "tool_name": "calendar_read", "tool_call_id": "call-1", "status": "success", - "result": "status=success total=3 returned=3", + "result": { + "command": "calendar", + "subcommand": "read", + "data": {"total": 3, "items": []}, + }, + "ui_hints": { + "intent": "status", + "status": "success", + "title": "完成", + "items": [], + "listItems": [], + "sections": [], + "actions": [], + }, }, }, "timestamp": "2026-03-17T09:00:01Z", @@ -461,8 +474,6 @@ async def test_get_history_snapshot_filters_out_tool_messages() -> None: "agent_output": { "status": "success", "answer": "今天共有 3 条日程。", - "key_points": [], - "result_type": "summary", "suggested_actions": [], }, }, @@ -484,7 +495,12 @@ async def test_get_history_snapshot_filters_out_tool_messages() -> None: current_user=_user(), ) - assert [message.role for message in snapshot.messages] == ["user", "assistant"] + assert [message.role for message in snapshot.messages] == [ + "user", + "tool", + "assistant", + ] + assert snapshot.messages[1].ui_schema is not None @pytest.mark.asyncio @@ -529,7 +545,7 @@ async def test_cancel_run_rejects_non_owner() -> None: phone="+8613812340000", ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.cancel_run( thread_id="00000000-0000-0000-0000-000000000001", run_id="run-cancel-2", diff --git a/backend/tests/unit/v1/agent/test_utils.py b/backend/tests/unit/v1/agent/test_utils.py index 22a9487..a865385 100644 --- a/backend/tests/unit/v1/agent/test_utils.py +++ b/backend/tests/unit/v1/agent/test_utils.py @@ -16,33 +16,43 @@ class _FakeMessage: self.timestamp = datetime.now(timezone.utc) -def test_convert_message_to_history_does_not_attach_ui_schema_for_tool_message() -> ( - None -): +def test_convert_message_to_history_attaches_ui_schema_for_tool_message() -> None: message = _FakeMessage( role="tool", - metadata={"tool_agent_output": {"result": "done"}}, - ) - - result = convert_message_to_history(message) # type: ignore[arg-type] - - assert "ui_schema" not in result - assert "uiSchema" not in result - - -def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -> None: - message = _FakeMessage( - role="assistant", metadata={ - "agent_output": {"ui_schema": {"version": "2.0", "root": {"type": "stack"}}} + "tool_agent_output": { + "result": {"status": "success"}, + "ui_hints": { + "intent": "status", + "status": "success", + "title": "完成", + "items": [], + "listItems": [], + "sections": [], + "actions": [], + }, + } }, ) result = convert_message_to_history(message) # type: ignore[arg-type] assert "ui_schema" in result - assert "uiSchema" not in result - assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}} + + +def test_convert_message_to_history_adds_suggested_actions_for_assistant_message() -> None: + message = _FakeMessage( + role="assistant", + metadata={ + "agent_output": { + "suggested_actions": ["查今天日程", "创建会议"] + } + }, + ) + + result = convert_message_to_history(message) # type: ignore[arg-type] + + assert result["suggestedActions"] == ["查今天日程", "创建会议"] def test_convert_message_to_history_returns_multiple_user_attachments() -> None: diff --git a/backend/tests/unit/v1/auth/test_auth_gateway.py b/backend/tests/unit/v1/auth/test_auth_gateway.py index 6076ecf..f38099d 100644 --- a/backend/tests/unit/v1/auth/test_auth_gateway.py +++ b/backend/tests/unit/v1/auth/test_auth_gateway.py @@ -5,11 +5,14 @@ from unittest.mock import MagicMock import pytest from core.http.errors import ApiProblemError +from core.config.settings import config from v1.auth.gateway import SupabaseAuthGateway from v1.auth.schemas import ( + AuthUser, OtpSendRequest, PhoneSessionCreateRequest, + SessionResponse, SessionRefreshRequest, ) @@ -28,6 +31,7 @@ class TestSupabaseAuthGateway: "v1.auth.gateway.supabase_service.get_admin_client", lambda: mock_admin_client, ) + monkeypatch.setattr(config.runtime, "environment", "test") return SupabaseAuthGateway(), mock_client, mock_admin_client @pytest.mark.asyncio @@ -106,6 +110,39 @@ class TestSupabaseAuthGateway: assert exc_info.value.status_code == 401 + @pytest.mark.asyncio + async def test_refresh_session_uses_dev_flow_in_dev_environment( + self, + gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + sut, mock_client, _ = gateway + monkeypatch.setattr(config.runtime, "environment", "dev") + + expected = SessionResponse( + access_token="dev-access", + refresh_token="dev-refresh", + expires_in=3600, + token_type="bearer", + user=AuthUser(id="user-dev", phone="+8613812345678"), + ) + + async def _fake_refresh_dev_phone_session(*, request: SessionRefreshRequest) -> SessionResponse: + assert request.refresh_token == "dev-refresh" + return expected + + monkeypatch.setattr( + "v1.auth.gateway.refresh_dev_phone_session", + _fake_refresh_dev_phone_session, + ) + + response = await sut.refresh_session( + SessionRefreshRequest(refresh_token="dev-refresh") + ) + + assert response == expected + assert mock_client.auth.refresh_session.call_count == 0 + @pytest.mark.asyncio async def test_get_user_by_phone_uses_in_memory_cache( self, diff --git a/backend/tests/unit/v1/auth/test_dev_phone_session.py b/backend/tests/unit/v1/auth/test_dev_phone_session.py new file mode 100644 index 0000000..db063a6 --- /dev/null +++ b/backend/tests/unit/v1/auth/test_dev_phone_session.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from uuid import UUID + +import pytest +from pydantic import SecretStr + +from core.config.settings import config +from core.http.errors import ApiProblemError +from v1.auth.dev_phone_session import create_dev_phone_session, refresh_dev_phone_session +from v1.auth.schemas import PhoneSessionCreateRequest, SessionRefreshRequest + + +_TEST_JWT_SECRET = "test-secret-key-with-32-bytes-minimum!!" + + +@pytest.mark.asyncio +async def test_dev_session_refresh_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config.runtime, "environment", "dev") + monkeypatch.setattr(config.supabase, "jwt_secret", SecretStr(_TEST_JWT_SECRET)) + monkeypatch.setattr(config.supabase, "jwt_issuer", "http://localhost:8001/auth/v1") + async def _fake_find_or_create_user_by_phone(_phone: str) -> UUID: + return UUID("00000000-0000-0000-0000-000000000123") + + monkeypatch.setattr( + "v1.auth.dev_phone_session._find_or_create_user_by_phone", + _fake_find_or_create_user_by_phone, + ) + + created = await create_dev_phone_session( + request=PhoneSessionCreateRequest(phone="+8613812345678", token="123456") + ) + refreshed = await refresh_dev_phone_session( + request=SessionRefreshRequest(refresh_token=created.refresh_token) + ) + + assert refreshed.user.id == "00000000-0000-0000-0000-000000000123" + assert refreshed.user.phone == "+8613812345678" + assert refreshed.access_token + assert refreshed.refresh_token + + +@pytest.mark.asyncio +async def test_dev_session_refresh_rejects_invalid_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(config.runtime, "environment", "dev") + monkeypatch.setattr(config.supabase, "jwt_secret", SecretStr(_TEST_JWT_SECRET)) + monkeypatch.setattr(config.supabase, "jwt_issuer", "http://localhost:8001/auth/v1") + + with pytest.raises(ApiProblemError) as exc_info: + await refresh_dev_phone_session( + request=SessionRefreshRequest(refresh_token="invalid-token") + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.code == "AUTH_REFRESH_TOKEN_INVALID" diff --git a/backend/tests/unit/v1/automation_jobs/test_repository.py b/backend/tests/unit/v1/automation_jobs/test_repository.py index e281a54..39272af 100644 --- a/backend/tests/unit/v1/automation_jobs/test_repository.py +++ b/backend/tests/unit/v1/automation_jobs/test_repository.py @@ -4,8 +4,8 @@ from uuid import uuid4 import pytest from models.automation_jobs import AutomationJobStatus, ScheduleType +from schemas.agent.skill_config import SkillName from schemas.domain.automation import ( - AgentTool, AutomationJobConfig, ContextSource, ContextWindowMode, @@ -23,7 +23,7 @@ from v1.automation_jobs.schemas import ( def _make_config() -> AutomationJobConfig: return AutomationJobConfig( input_template="Hello", - enabled_tools=[AgentTool.MEMORY_WRITE], + enabled_skills=[SkillName.MEMORY], context=MessageContextConfig( source=ContextSource.LATEST_CHAT, window_mode=ContextWindowMode.DAY, @@ -119,7 +119,7 @@ async def test_update_merges_config_and_recomputes_next_run() -> None: existing_job.timezone = "UTC" existing_job.config = { "input_template": "Old", - "enabled_tools": ["memory.write"], + "enabled_skills": ["memory"], "context": { "source": "latest_chat", "window_mode": "day", @@ -136,7 +136,7 @@ async def test_update_merges_config_and_recomputes_next_run() -> None: data = AutomationJobUpdateRequest( config=AutomationJobConfig( - enabled_tools=[AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET], + enabled_skills=[SkillName.MEMORY], schedule=ScheduleConfig( type=ScheduleType.WEEKLY, run_at=ScheduleRunAt(hour=10, minute=30), @@ -150,8 +150,8 @@ async def test_update_merges_config_and_recomputes_next_run() -> None: update_values = repository.update_by_id.call_args[0][1] assert "config" in update_values assert "next_run_at" in update_values - enabled_tools = update_values["config"]["enabled_tools"] - assert isinstance(enabled_tools[0], str) + enabled_skills = update_values["config"]["enabled_skills"] + assert isinstance(enabled_skills[0], str) @pytest.mark.asyncio diff --git a/backend/tests/unit/v1/automation_jobs/test_schemas.py b/backend/tests/unit/v1/automation_jobs/test_schemas.py index 8366854..4725eb3 100644 --- a/backend/tests/unit/v1/automation_jobs/test_schemas.py +++ b/backend/tests/unit/v1/automation_jobs/test_schemas.py @@ -5,7 +5,7 @@ from uuid import uuid4 import pytest from pydantic import ValidationError -from core.agentscope.tools.tool_config import AgentTool +from schemas.agent.skill_config import SkillName from schemas.domain.automation import AutomationJobConfig from v1.automation_jobs.schemas import ( AutomationJobCreateRequest, @@ -22,7 +22,7 @@ def _mock_orm_job() -> MagicMock: mock_orm_job.title = "Test Job" mock_orm_job.config = { "input_template": "Hello", - "enabled_tools": ["memory.write", "memory.forget"], + "enabled_skills": ["memory"], "context": { "source": "latest_chat", "window_mode": "day", @@ -74,7 +74,7 @@ def test_create_request_valid_timezone() -> None: "timezone": "Asia/Shanghai", "config": { "input_template": "Hello", - "enabled_tools": ["memory.write"], + "enabled_skills": ["memory"], "context": { "source": "latest_chat", "window_mode": "day", @@ -102,7 +102,7 @@ def test_update_timezone_validation() -> None: def test_config_patch_still_allows_partial_payload() -> None: patch = AutomationJobConfig.model_validate( - {"enabled_tools": [AgentTool.MEMORY_WRITE]} + {"enabled_skills": [SkillName.MEMORY]} ) assert patch.input_template is None - assert patch.enabled_tools == [AgentTool.MEMORY_WRITE] + assert patch.enabled_skills == [SkillName.MEMORY] diff --git a/backend/tests/unit/v1/automation_jobs/test_service.py b/backend/tests/unit/v1/automation_jobs/test_service.py index 14d7ded..b2e0bce 100644 --- a/backend/tests/unit/v1/automation_jobs/test_service.py +++ b/backend/tests/unit/v1/automation_jobs/test_service.py @@ -4,9 +4,18 @@ from uuid import UUID, uuid4 import pytest from sqlalchemy.exc import SQLAlchemyError -from core.http.errors import ApiProblemError +from core.http.errors import ApiProblemError from models.automation_jobs import AutomationJobStatus, ScheduleType +from schemas.agent.skill_config import SkillName +from schemas.domain.automation import ( + AutomationJobConfig, + ContextSource, + ContextWindowMode, + MessageContextConfig, + ScheduleConfig, + ScheduleRunAt, +) from v1.automation_jobs.service import ( AutomationJobLimitExceeded, AutomationJobNotFound, @@ -17,21 +26,12 @@ from v1.automation_jobs.schemas import ( AutomationJobCreateRequest, AutomationJobUpdateRequest, ) -from schemas.domain.automation import ( - AgentTool, - AutomationJobConfig, - ContextSource, - ContextWindowMode, - MessageContextConfig, - ScheduleConfig, - ScheduleRunAt, -) def _make_config() -> AutomationJobConfig: return AutomationJobConfig( input_template="Hello", - enabled_tools=[AgentTool.MEMORY_WRITE], + enabled_skills=[SkillName.MEMORY], context=MessageContextConfig( source=ContextSource.LATEST_CHAT, window_mode=ContextWindowMode.DAY, @@ -65,7 +65,7 @@ def _make_job( job.status = AutomationJobStatus.ACTIVE job.config = { "input_template": "Hello", - "enabled_tools": ["memory.write"], + "enabled_skills": ["memory"], "context": { "source": "latest_chat", "window_mode": "day", diff --git a/backend/tests/unit/v1/schedule_items/test_subscription.py b/backend/tests/unit/v1/schedule_items/test_subscription.py index 14381b4..5aaf84a 100644 --- a/backend/tests/unit/v1/schedule_items/test_subscription.py +++ b/backend/tests/unit/v1/schedule_items/test_subscription.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 import pytest -from fastapi import HTTPException +from core.http.errors import ApiProblemError from core.auth.models import CurrentUser from models.inbox_messages import InboxMessage, InboxMessageStatus @@ -132,7 +132,7 @@ async def test_accept_subscription_not_found( inbox_repository=FakeInboxRepo(None), ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.accept_subscription(item_id) assert exc_info.value.status_code == 404 @@ -177,7 +177,7 @@ async def test_reject_subscription_not_found( inbox_repository=FakeInboxRepo(None), ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.reject_subscription(item_id) assert exc_info.value.status_code == 404 diff --git a/docs/protocols/agent/api-endpoints.md b/docs/protocols/agent/api-endpoints.md index e855ca5..e15fb15 100644 --- a/docs/protocols/agent/api-endpoints.md +++ b/docs/protocols/agent/api-endpoints.md @@ -161,19 +161,28 @@ run 过滤语义: messages: Array<{ id: string; seq: number; - role: "user" | "assistant"; + role: "user" | "assistant" | "tool"; content: string; + suggestedActions?: string[]; attachments?: Array<{ // user 附件签名链接列表 mimeType: string; url: string; }>; - ui_schema?: object | null; // assistant 的编译后 UI + ui_schema?: object | null; // tool 的编译后 UI timestamp: string; // ISO-8601 }>; } ``` -tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回。续接时以 `metadata.tool_agent_output` 作为主信源(`content` 为轻量摘要)。 +`/history` 会返回 tool 消息用于 UI 重建。tool 消息的 `ui_schema` 来自 `metadata.tool_agent_output.ui_hints` 的编译结果。 + +`messages[].content` 在当前协议中始终是字符串: + +- assistant: answer 文本 +- tool: tool result 的 JSON 文本投影 +- user: 用户输入文本 + +结构化字段(如 tool result/ui hints、suggested actions)通过 metadata 派生,不要求把 `content` 升级为 JSON 对象。 可见性说明: diff --git a/docs/protocols/agent/run-agent-input.md b/docs/protocols/agent/run-agent-input.md index d857a7d..c5e08de 100644 --- a/docs/protocols/agent/run-agent-input.md +++ b/docs/protocols/agent/run-agent-input.md @@ -204,8 +204,8 @@ interface ForwardedProps { | runtime_mode | 说明 | Pipeline | 差异 | |--------------|------|----------|------| -| `chat` | 标准对话模式 | `router` -> `worker` | `enabled_tools` 和 `context` 来自 `system_agents.yaml` | -| `automation` | 自动化任务模式 | `router` -> `worker` | `enabled_tools` 和 `context` 来自 `AutomationJob.config`(通过 `runtime_config` 注入)| +| `chat` | 标准对话模式 | `router` -> `worker` | `enabled_skills` 和 `context` 来自 `system_agents.yaml` | +| `automation` | 自动化任务模式 | `router` -> `worker` | `enabled_skills` 和 `context` 来自 `AutomationJob.config`(通过 `runtime_config` 注入)| > `runtime_mode` 仅影响 `RuntimeConfig`(工具列表与上下文配置),不改变执行阶段。两模式均使用固定两阶段 pipeline。 @@ -454,7 +454,7 @@ interface HistoryMessageAssistant { seq: number; role: "assistant"; content: string; - ui_schema: UiSchemaRenderer | null; // 由 agent_output.ui_hints 编译 + ui_schema: UiSchemaRenderer | null; // 当前 assistant 文本消息默认不携带 UI,通常为 null timestamp: string; } diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index 3666305..c199fb1 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -127,6 +127,11 @@ data: ### 3.3 Tool 事件 +前端渲染约束(当前实现): + +- tool UI 渲染仅消费 `TOOL_CALL_RESULT.ui_schema`。 +- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` 仅作为执行观测事件保留,前端主聊天流不渲染中间态卡片。 + #### `TOOL_CALL_START` ```json @@ -184,18 +189,22 @@ data: "tool_call_id": "...", "tool_call_args": {}, "status": "success" | "failure" | "partial", - "result": "...", - "error": null + "result": {}, + "error": null, + "ui_schema": {} } ``` -说明:`TOOL_CALL_RESULT` 不再携带 `ui_schema`。tool 结果通过 `result` 字段提供紧凑、结构化、可执行的信息(优先包含 id/status/count 等关键事实),用于 agent 后续推理与工具编排。 +说明:`TOOL_CALL_RESULT` 中 `result` 字段提供紧凑、结构化、可执行的信息(优先包含 id/status/count 等关键事实),用于 agent 后续推理与工具编排。若对应工具输出存在 `ui_hints`,后端会在 codec 层编译得到 `ui_schema` 并随事件下发。 + +当前 `ui_hints` 策略:仅对当前 canonical CLI 的 CRUD 子命令生成(`calendar.create/read/update/delete`、`contacts.read`、`memory.update`);`calendar.share` 不生成 `ui_hints`。 补充约束: - `tool_call_id` 必须与同次调用的 `TOOL_CALL_START/ARGS/END.toolCallId` 一致,并在每次工具调用中保持唯一。 - `tool_call_args` 仅表示输入参数快照。 - `result` 仅表示执行输出事实,不重复 `tool_call_args` 已包含的输入参数。 +- `ui_schema` 为可渲染 UI 线缆格式;其源数据来自 `metadata.tool_agent_output.ui_hints`。 #### 3.3.1 tool 名称展示规范(前端本地化) @@ -206,21 +215,22 @@ SSE 协议中的工具名字段保持后端原样,不做服务端翻译: 前端展示层统一通过工具名本地化映射进行中文渲染,要求兼容两类命名风格: -- dot 风格:`memory.write`、`calendar.read` -- snake 风格:`memory_write`、`calendar_read` +- dot 风格:`memory.update`、`calendar.read` +- snake 风格:`memory_update`、`calendar_read` 当前规范映射(canonical -> 中文)如下: - `calendar.read` -> `读取日程` -- `calendar.write` -> `写入日程` +- `calendar.create` -> `创建日程` +- `calendar.update` -> `更新日程` +- `calendar.delete` -> `删除日程` - `calendar.share` -> `共享日程` -- `user.lookup` -> `查找联系人` -- `memory.write` -> `写入记忆` -- `memory.forget` -> `清理记忆` +- `contacts.read` -> `读取联系人` +- `memory.update` -> `更新记忆` 兼容策略: -1. 优先按 alias 归一化(例如 `memory_write` -> `memory.write`) +1. 优先按 alias 归一化(例如 `memory_update` -> `memory.update`) 2. 命中 canonical 映射后展示中文 3. 未命中时回退显示原始工具名(保证向后兼容) @@ -242,11 +252,8 @@ SSE 协议中的工具名字段保持后端原样,不做服务端翻译: "stage": "worker", "status": "success" | "partial_success" | "failed", "answer": "...", - "key_points": [], - "result_type": "execution_report" | "clarification" | "error_report" | "unknown", "suggested_actions": [], - "error": null, - "ui_schema": {} + "error": null } ``` @@ -427,5 +434,5 @@ WHERE (visibility_mask & 2) != 0 | agent 输出 visibility_mask | `UI_HISTORY \| CONTEXT_ASSEMBLY`(memory stage 仅 `UI_HISTORY`) | `UI_HISTORY` | | 进入 /history | ✅ | ✅(仅 agent 输出) | | 进入 context assembly | ✅(自动) | ❌(通过 run_input 注入) | -| enabled_tools 来源 | `system_agents.yaml` worker 配置 | `AutomationJob.config.enabled_tools` | +| enabled_skills 来源 | `system_agents.yaml` worker 配置 | `AutomationJob.config.enabled_skills` | | context 配置来源 | `system_agents.yaml` router context_messages | `AutomationJob.config.context` | diff --git a/docs/protocols/agent/tool-protocol.md b/docs/protocols/agent/tool-protocol.md new file mode 100644 index 0000000..4283edb --- /dev/null +++ b/docs/protocols/agent/tool-protocol.md @@ -0,0 +1,240 @@ +# Agent Tool Protocol + +本文件定义当前项目中 AgentScope tool、项目 CLI、tool post-processor、SSE/history/persistence 之间的协议边界。 + +## 1. Scope + +本协议覆盖: + +- AgentScope tool wrapper +- 项目内受限 CLI tool +- runtime tool post-processor +- `ToolResponse` +- `ToolAgentOutput` +- tool message `content` +- tool 结果的 SSE/history/persistence 表达 + +本协议不覆盖: + +- 前端视觉实现细节 +- worker answer 文案格式 +- 非 tool 的普通 assistant 文本输出 + +## 2. Core Principles + +1. 项目 CLI 是受限工具执行边界,不是通用 shell。 +2. agent 只暴露一个 AgentScope tool:`project_cli`。 +3. skills 只负责向 agent 披露如何使用 `project_cli`,不承担执行 transport 或权限决策。 +4. Router 是 CLI 的唯一命令分发核心,只允许白名单 `command + subcommand`。 +5. 每个 CLI 子命令绑定 Python handler。 +6. handler 只能调用允许的内部能力,不开放任意系统命令执行。 +6.1 `project_cli` 命令权限由 runtime `allowed_commands` 与 CLI router 白名单共同约束,不能由 skills 启用状态隐式放开。 +7. `ToolAgentOutput.result` 是 canonical machine-oriented tool result。 +8. `ToolResponse` 不承载完整 `ToolAgentOutput`,只承载给 agent 使用的文本投影。 +9. tool UI 只来自 `ToolAgentOutput.ui_hints`,不再经过 worker `ui_hints -> ui_schema` 链路。 + +## 3. Execution Flow + +一次 tool 调用按如下顺序执行: + +1. AgentScope tool `project_cli` 接收到模型生成的 tool call。 +2. wrapper 将 `command + subcommand + args` 映射为项目 CLI 输入。 +3. runtime 将受控认证凭证通过环境变量注入 CLI 子进程。 +4. CLI router 将 `(command, subcommand)` 分发给对应 Python handler。 +5. handler 执行业务逻辑并返回结构化 `result`。 +6. wrapper 将 `result` 投影为文本,写入 `ToolResponse.content`。 +7. runtime tool post-processor 基于 `result` 和 runtime 上下文生成完整 `ToolAgentOutput`。 +8. `ToolAgentOutput` 进入: + - `TOOL_CALL_RESULT` + - `metadata.tool_agent_output` + - history replay + - context rebuild + +## 4. Input Channels + +Agent -> `project_cli` 的结构化入参: + +```json +{ + "command": "calendar", + "subcommand": "read", + "args": { + "start_at": "2026-04-21T00:00:00+08:00", + "end_at": "2026-04-22T00:00:00+08:00" + } +} +``` + +CLI 运行时输入通道采用“两者结合”: + +- `argv` 为主: + - command + - subcommand + - mode / formatting flags +- `stdin` 为辅: + - 较大的 JSON payload + - 复杂对象参数 + - 多步批量操作负载 +- environment variables: + - controlled credential + - runtime-only internal context + +约束: + +- 模型不可见的认证信息不得进入 tool args。 +- CLI 不接受来自自然语言/模型参数的任意 token 字符串。 +- backend runtime 只能通过受控环境变量注入认证凭证。 + +权限边界: + +- `enabled_skills` 仅控制 skill 文档可见性与注册。 +- `allowed_commands` 控制 `project_cli` 可执行命令集合。 +- 两者职责解耦,避免“技能可见即命令授权”的隐式耦合。 + +## 5. CLI Output Contract + +CLI handler 的原始成功输出必须是统一结构化结果。 + +示例: + +```json +{ + "ok": true, + "command": "calendar", + "subcommand": "read", + "data": { + "items": [ + { + "id": "evt_123", + "title": "Project sync", + "startAt": "2026-04-21T10:00:00+08:00" + } + ], + "count": 1 + } +} +``` + +失败时,CLI handler 必须返回结构化错误结果。 + +## 6. ToolResponse Contract + +`ToolResponse` 只用于给 agent 提供 tool 结果文本投影。 + +规则: + +- `ToolResponse.content` 只包含 `result` 的完整 JSON 文本投影。 +- 不再把完整 `ToolAgentOutput` 序列化后塞进 `ToolResponse.content`。 +- 文本投影必须与 `result` 保持等价信息量,不做摘要裁剪。 + +示例: + +```json +{"status":"success","items":[{"id":"evt_123","title":"Project sync","startAt":"2026-04-21T10:00:00+08:00"}],"count":1} +``` + +## 7. Tool Post-Processor Contract + +runtime 必须在 tool 调用完成后运行 tool post-processor。 + +post-processor 负责生成完整 `ToolAgentOutput`,至少包括: + +- `tool_name`(固定为 `project_cli`) +- `tool_call_id` +- `tool_call_args` +- `status` +- `result` +- `error` +- `ui_hints` + +规则: + +- `result` 是真源。 +- `result` 应保留 `command`、`subcommand` 和 `data`。 +- `ui_hints` 由 post-processor 生成,不由 worker 生成。 +- tool 失败时 `error` 必须为结构化对象。 +- `status` 必须是 `success | failure | partial`。 + +`ui_hints` 输出范围(当前协议): + +- 输出:当前 CLI canonical 子命令中的 CRUD 调用 + - `calendar.create` + - `calendar.read` + - `calendar.update` + - `calendar.delete` + - `contacts.read` + - `memory.update` +- 不输出:非 CRUD 子命令(例如 `calendar.share`) + +## 8. ToolAgentOutput Contract + +`ToolAgentOutput` 用于系统内部和前端消费,不直接作为模型上下文主输入。 + +消费位置: + +- `TOOL_CALL_RESULT` +- 数据库存储 `metadata.tool_agent_output` +- `/history` tool UI replay +- cold-path context rebuild + +规则: + +- `tool_name` 固定为 `project_cli`。 +- `result` 必须是 JSON-native、machine-oriented 结构。 +- 必须包含后续链式调用所需的 ID/outcome/status/count 等事实。 +- `ui_hints` 是 tool UI 的唯一真源。 + +## 9. History Replay Contract + +`/history` 必须支持 tool UI 回放。 + +规则: + +- history 对外返回 tool message。 +- tool message 的 UI 恢复从 `metadata.tool_agent_output.ui_hints` 读取,编译为 `ui_schema` 后返回。 +- tool message `content` 仍是 `result` 的 JSON 文本投影。 + +### 9.1 `messages.content` 存储类型决策 + +- 当前决策:`messages.content` 继续保持 `text`,不迁移到 `jsonb`。 +- 原因: + - `messages` 表承担多角色消息(user/assistant/tool),`content` 统一作为文本载荷更稳定; + - tool 的结构化数据已经由 `metadata.tool_agent_output.result` 与 `metadata.tool_agent_output.ui_hints` 承载; + - `/history`、SSE、context rebuild 当前都按“`content` 文本 + metadata 结构化字段”工作,避免双轨 schema 演进; + - 实测出现过 dict 直接写入 `messages.content` 导致驱动类型错误(`expected str, got dict`),保持 `text` 可减少写入歧义。 +- 约束:凡写入 `messages.content` 的数据必须是字符串;结构化对象必须进入 `metadata`。 + +## 10. SSE Contract + +规则: + +- `TEXT_MESSAGE_END` 不再包含 worker `ui_hints` 或 `ui_schema`。 +- `TOOL_CALL_RESULT` 携带 `ui_schema`(由后端 codec 从 `ui_hints` 编译而来)。 +- tool UI 前端消费基于 `ui_schema`(由 `ui_hints` 编译)。 + +## 11. Controlled Credential Contract + +tool runtime 的认证边界使用 controlled credential。 + +规则: + +- chat 与 automation 都不得把 `owner_id` 当作凭证。 +- controlled credential 由当前 bearer token 签发方在同一信任边界内签发。 +- TTL 目标为 5-10 分钟。 +- 该凭证只覆盖 tool runtime 所需权限窗口。 +- 凭证仅通过 backend-controlled env 注入 CLI。 +- 日志、错误响应、history、SSE 中不得暴露原始凭证。 + +## 12. Security Constraints + +- 不开放 shell 执行能力。 +- 不允许通过 tool args 传任意 token。 +- 不允许通过 `owner_id` 伪造用户 Bearer token。 +- 不允许把 DB session 直接注入 CLI 边界。 + +## 13. Compatibility Strategy + +- 策略:`backward-compatible`。 +- `ui_schema` 作为 wire format 保留,由后端 codec 从 `ui_hints` 编译而来。 +- 前端 renderer 继续消费 `ui_schema`。 +- `ui_hints` 作为内部字段,不直接传输给前端。 diff --git a/docs/protocols/models/automation-jobs.md b/docs/protocols/models/automation-jobs.md index 0fccbe4..6ae8e8d 100644 --- a/docs/protocols/models/automation-jobs.md +++ b/docs/protocols/models/automation-jobs.md @@ -14,7 +14,7 @@ scheduler computation, and Flutter settings pages. - `is_system`: boolean (`bootstrap_key != null` 时为 `true`,只读派生字段) - `config`: object - `input_template`: string - - `enabled_tools`: string[] + - `enabled_skills`: string[] (`calendar | contacts | memory`) - `context`: object - `source`: `latest_chat` - `window_mode`: `day | number` diff --git a/docs/protocols/ui/data-flow.md b/docs/protocols/ui/data-flow.md index 6a31118..ba2aa10 100644 --- a/docs/protocols/ui/data-flow.md +++ b/docs/protocols/ui/data-flow.md @@ -93,12 +93,12 @@ data: ### 5.2 UI 编译器一致 -两条链路都使用后端 `ui_compiler.compile(...)` 将 **worker** 的 `ui_hints` 编译为可渲染结构: +两条链路都使用后端 `ui_compiler.compile(...)` 将 **tool** 的 `ui_hints` 编译为可渲染结构: - events:在 runtime 发送事件前编译,字段名为 `ui_schema` - history:在历史转换时编译,字段名为 `ui_schema` -tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args` + `result` 组合。 +tool 结果走 UI 编译链路:`TOOL_CALL_RESULT` 在保留 `tool_call_args` + `result` 的同时可携带 `ui_schema`。 - `metadata.tool_agent_output` 是 tool 消息的完整信源(用于 runtime observation 与 history replay)。 - `message.content` 保持轻量摘要(当前等于 `result`)。 @@ -126,10 +126,9 @@ tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args ### 7.1 后端生成 -- runtime 使用 `ui_hints.action.type = navigation` 产出导航动作。 +- runtime 基于 tool 输出中的 `ui_hints.action.type = navigation` 产出导航动作。 - 编译后在 `ui_schema` 中保持 `action.type = navigation`、`action.path`、`action.params`。 -- 路由来源应受后端静态路由目录约束: - - `backend/src/core/config/static/route/frontend_routes.yaml` +- 路由由工具能力直接给出 concrete path,agent 本身不需要维护 route_id 概念。 ### 7.2 前端消费(统一解析规则) @@ -144,12 +143,12 @@ tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args - 关键业务动作(创建、编辑、分享、处理邀请等)应优先设计为可深链页面路由,而不是仅存在于临时弹层。 - 若 UI 采用 sheet 风格展示,也应由页面路由承载状态,再以页面内 surface 呈现 sheet 视觉。 - `todo.edit` 必须落地为独立子页面(`/todo/{id}/edit`),不应通过详情页内弹窗承载编辑主流程。 -- 推荐后端优先使用以下 route_id 生成导航(示例): - - `calendar.event_create` -> `/calendar/events/new` - - `calendar.event_edit` -> `/calendar/events/{id}/edit` - - `calendar.event_share` -> `/calendar/events/{id}/share` - - `todo.create` -> `/todo/new` - - `todo.edit` -> `/todo/{id}/edit` +- 推荐工具能力优先输出以下 concrete path(示例): + - `/calendar/events/new` + - `/calendar/events/{id}/edit` + - `/calendar/events/{id}/share` + - `/todo/new` + - `/todo/{id}/edit` ### 7.4 约束建议 diff --git a/docs/protocols/ui/ui-schema.md b/docs/protocols/ui/ui-schema.md index 39cec15..d321ec5 100644 --- a/docs/protocols/ui/ui-schema.md +++ b/docs/protocols/ui/ui-schema.md @@ -294,8 +294,8 @@ interface NavigateAction { // 2) path MUST NOT include query string or fragment. // 3) params, when provided, is treated as query params only. // 4) params values MUST be scalar (string | number | boolean). -// 5) Backend MUST generate path from route catalog -// `backend/src/core/config/static/route/frontend_routes.yaml`. +// 5) Backend/tool layer MUST generate concrete internal path directly. +// Agent prompt does not carry route catalog contract. // URL action interface UrlAction {