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 index bef589f..9d3a848 100644 --- 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 @@ -404,3 +404,7 @@ Do not mark an item complete until the code, docs, and verification for that ite - [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/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 index 44e7aa4..3bae55d 100644 --- 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 @@ -14,23 +14,15 @@ "branch": "worktree/refactor-tool-cli-skill-ui-schema", "base_branch": "dev", "worktree_path": ".worktrees/refactor-tool-cli-skill-ui-schema", - "current_phase": 2, + "current_phase": 5, "next_action": [ { - "phase": 1, - "action": "implement" - }, - { - "phase": 2, + "phase": 5, "action": "check" }, { - "phase": 3, - "action": "finish" - }, - { - "phase": 4, - "action": "create-pr" + "phase": 5, + "action": "implement" } ], "commit": null, @@ -63,6 +55,14 @@ { "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": [], @@ -79,8 +79,8 @@ "docs/protocols/ui/data-flow.md", "docs/protocols/agent/sse-events.md" ], - "notes": "Protocol docs must be updated before backend/frontend contract changes. Current implementation still compiles worker ui_hints into ui_schema and registers tools directly as Python functions.", + "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" } -} \ No newline at end of file +} 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/chat/ag_ui_event.dart b/apps/lib/core/chat/ag_ui_event.dart index 8d1ff07..6acf0c9 100644 --- a/apps/lib/core/chat/ag_ui_event.dart +++ b/apps/lib/core/chat/ag_ui_event.dart @@ -5,9 +5,6 @@ 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'; } @@ -18,9 +15,6 @@ enum AgUiEventType { stepStarted, stepFinished, textMessageEnd, - toolCallStart, - toolCallArgs, - toolCallEnd, toolCallResult, unknown, } @@ -32,9 +26,6 @@ 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, }; @@ -45,9 +36,6 @@ 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.unknown: '', }; @@ -74,9 +62,6 @@ 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.unknown => UnknownAgUiEvent(rawJson: json), }; @@ -157,12 +142,14 @@ class TextMessageEndEvent extends AgUiEvent { required this.answer, required this.role, required this.status, + this.suggestedActions = const [], }) : super(type: AgUiEventType.textMessageEnd); final String messageId; final String answer; final String role; final String status; + final List suggestedActions; factory TextMessageEndEvent.fromJson(Map json) => TextMessageEndEvent( @@ -170,53 +157,17 @@ class TextMessageEndEvent extends AgUiEvent { answer: _asString(json['answer']), role: _asString(json['role'], fallback: 'assistant'), status: _asString(json['status'], fallback: 'success'), + 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); @@ -224,7 +175,8 @@ class ToolCallResultEvent extends AgUiEvent { final String messageId; final String toolCallId; final String toolName; - final String resultSummary; + final Map? toolCallArgs; + final Object? result; final String status; final Map? uiSchema; @@ -233,7 +185,8 @@ class ToolCallResultEvent extends AgUiEvent { 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'), uiSchema: _asMap(json['ui_schema']), ); @@ -280,6 +233,7 @@ class HistoryMessage { required this.content, required this.timestamp, this.attachments = const [], + this.suggestedActions = const [], this.uiSchema, }); @@ -289,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( @@ -298,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']), ); } @@ -374,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/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/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..2e70696 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -257,7 +257,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'; 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 900239d..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,12 +38,6 @@ 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.unknown: @@ -84,12 +67,13 @@ extension _ChatBlocEvents on ChatBloc { state.items, event.messageId, event.answer, + event.suggestedActions, timestamp, ); emit( state.copyWith( - items: _removeToolCallItems(items), + items: items, currentMessageId: null, isWaitingFirstToken: false, isStreaming: false, @@ -101,6 +85,7 @@ extension _ChatBlocEvents on ChatBloc { List items, String messageId, String content, + List suggestedActions, DateTime timestamp, ) { final result = List.from(items); @@ -110,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; } @@ -121,73 +110,18 @@ extension _ChatBlocEvents on ChatBloc { timestamp: timestamp, sender: MessageSender.ai, isStreaming: false, + suggestedActions: suggestedActions, ), ); return result; } - 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()); } final timestamp = DateTime.now(); - final items = state.items.map((item) { - if (item is ToolCallItem && item.id == event.toolCallId) { - return item.copyWith(status: ToolCallStatus.completed); - } - return item; - }).toList(); + final items = List.from(state.items); final uiSchema = event.uiSchema; if (uiSchema != null) { @@ -219,30 +153,12 @@ extension _ChatBlocEvents on ChatBloc { items.add(uiItem); } - 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( @@ -262,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..b6b8f39 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -375,7 +375,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, + ), + ), ), ], ); 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/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/features/chat/presentation/bloc/chat_bloc_test.dart b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart index a5406ec..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,8 +250,9 @@ 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, ), diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 2a9952f..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 @@ -339,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/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 86b0f49..a5b67ba 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -22,6 +22,11 @@ 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 @@ -37,7 +42,7 @@ from schemas.agent.runtime_models import ( RouterAgentOutput, WorkerAgentOutputLite, ) -from schemas.agent.skill_config import SkillName +from schemas.agent.skill_config import ProjectCliCommand, SkillName from schemas.agent.system_agent import ( AgentType, SystemAgentLLMConfig, @@ -107,7 +112,8 @@ class AgentScopeRunner: agent_type=AgentType.WORKER, ) worker_toolkit = self._build_toolkit( - enabled_skills=runtime_config.enabled_skills + enabled_skills=runtime_config.enabled_skills, + allowed_commands=runtime_config.allowed_commands, ) router_output = await self._execute_router_step( @@ -174,10 +180,13 @@ class AgentScopeRunner: self, *, enabled_skills: list[SkillName], + allowed_commands: list[ProjectCliCommand], ) -> Any: 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( - enabled_skill_names=enabled_skill_names if enabled_skill_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( @@ -388,56 +397,66 @@ class AgentScopeRunner: 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, - 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 + 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, diff --git a/backend/src/core/agentscope/tools/cli/adapter.py b/backend/src/core/agentscope/tools/cli/adapter.py index 49dc532..d19a48a 100644 --- a/backend/src/core/agentscope/tools/cli/adapter.py +++ b/backend/src/core/agentscope/tools/cli/adapter.py @@ -63,6 +63,12 @@ async def invoke_cli_tool( 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, diff --git a/backend/src/core/agentscope/tools/cli/handler_calendar.py b/backend/src/core/agentscope/tools/cli/handler_calendar.py index 171e741..9b62857 100644 --- a/backend/src/core/agentscope/tools/cli/handler_calendar.py +++ b/backend/src/core/agentscope/tools/cli/handler_calendar.py @@ -1,7 +1,9 @@ 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 ( @@ -12,6 +14,7 @@ from core.agentscope.tools.utils.calendar_domain import ( 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, @@ -24,83 +27,181 @@ from v1.schedule_items.schemas import ( async def handle_calendar_read(request: CliCommand) -> CliCommandResult: from core.db.session import AsyncSessionLocal - start_at = str(request.args.get("start_at", "")) - end_at = str(request.args.get("end_at", "")) - parsed_start = parse_iso_datetime(start_at) - parsed_end = parse_iso_datetime(end_at) + 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") + 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") + 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}) + return CliCommandResult( + ok=True, + command="calendar", + subcommand="read", + data={"total": len(event_items), "items": event_items}, + ) -async def handle_calendar_write(request: CliCommand) -> CliCommandResult: +async def handle_calendar_create(request: CliCommand) -> CliCommandResult: from core.db.session import AsyncSessionLocal - operations = request.args.get("operations") - if not isinstance(operations, list): - operations = [] async with AsyncSessionLocal() as session: service = create_schedule_service(session, UUID(request.owner_id)) - - success_count = 0 - failed_count = 0 - success_ids: list[str] = [] - result_items: list[dict[str, Any]] = [] - - for op in operations: - action = op.get("action") - try: - if action == "create": - res = await _create_event(service, op) - success_count += 1 - success_ids.append(res["eventId"]) - result_items.append(res) - elif action == "update": - res = await _update_event(service, op) - success_count += 1 - success_ids.append(res["eventId"]) - result_items.append(res) - elif action == "delete": - event_id = op.get("event_id") - if not event_id: - raise ValueError("delete requires event_id") - await service.delete(UUID(event_id)) - success_count += 1 - success_ids.append(event_id) - result_items.append({"status": "success", "eventId": event_id}) - else: - raise ValueError(f"unknown action: {action}") - except Exception as exc: - code, message, _ = map_calendar_exception(exc) - failed_count += 1 - result_items.append({ + 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", - "eventId": op.get("event_id"), - "code": code, - "message": message, - }) + "success": 0, + "failed": 1, + "ids": [], + "results": [ + { + "action": "create", + "status": "failure", + "eventId": "", + "code": code, + "message": message, + } + ], + }, + error=ErrorInfo(code=code, message=message, retryable=retryable), + ) - status = _batch_status(success_count, failed_count) - return CliCommandResult( - ok=status != "failure", - command=request.command, - subcommand=request.subcommand, - data={ - "status": status, - "success": success_count, - "failed": failed_count, - "ids": success_ids, - "results": result_items, - }, - ) + +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: @@ -121,7 +222,14 @@ async def handle_calendar_share(request: CliCommand) -> CliCommandResult: 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"}) + result_items.append( + { + "phone": raw_phone, + "status": "failure", + "code": "INVALID_ARGUMENT", + "message": "invalid phone", + } + ) continue permission = { "permission_view": inv.get("permission_view", True), @@ -129,12 +237,22 @@ async def handle_calendar_share(request: CliCommand) -> CliCommandResult: "permission_invite": inv.get("permission_invite", False), } try: - await service.share(target_uuid, ScheduleItemShareRequest(phone=normalized_phone, **permission)) + 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}) + 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) @@ -152,64 +270,101 @@ async def handle_calendar_share(request: CliCommand) -> CliCommandResult: ) -async def _create_event(service: Any, op: dict[str, Any]) -> dict[str, Any]: - start_at = op.get("start_at") - if not start_at: +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 = op.get("event_timezone") - if not event_timezone: + 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") - end_at = op.get("end_at") - parsed_end = parse_iso_datetime(end_at) if end_at else None + + 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=(op.get("title") or "new event").strip(), - description=op.get("description", "").strip() or None, + 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( - op.get("location"), - op.get("color"), - op.get("reminder_minutes"), + args.get("location"), + args.get("color"), + args.get("reminder_minutes"), ), ) ) - return {"status": "success", "eventId": str(created.id)} + return {"action": "create", "status": "success", "eventId": str(created.id)} -async def _update_event(service: Any, op: dict[str, Any]) -> dict[str, Any]: - event_id = op.get("event_id") - if not event_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 op: - update_data["title"] = op["title"].strip() - if "description" in op: - update_data["description"] = op["description"].strip() - if "start_at" in op: - update_data["start_at"] = parse_iso_datetime(op["start_at"]) - if "end_at" in op: - update_data["end_at"] = parse_iso_datetime(op["end_at"]) - if "event_timezone" in op: - update_data["timezone"] = op["event_timezone"].strip() - if "status" in op: - update_data["status"] = ScheduleItemStatus(op["status"]) - if any(k in op for k in ("location", "color", "reminder_minutes")): + 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=op.get("location"), - color=op.get("color"), - reminder_minutes=op.get("reminder_minutes"), + 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 {"status": "success", "eventId": str(updated.id), "changedFields": changed_fields} + 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: @@ -222,7 +377,12 @@ def _normalize_phone(raw: str) -> str: 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": + if ( + len(phone) != 14 + or not phone.startswith("+861") + or not phone[1:].isdigit() + or phone[4] not in "3456789" + ): return "" return phone @@ -235,9 +395,52 @@ def _batch_status(success: int, failed: int) -> str: return "partial" -def _fail(*, request: CliCommand, code: str, message: str) -> CliCommandResult: - from schemas.agent.runtime_models import ErrorInfo +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, diff --git a/backend/src/core/agentscope/tools/cli/handler_contacts.py b/backend/src/core/agentscope/tools/cli/handler_contacts.py index ce827bc..bb24243 100644 --- a/backend/src/core/agentscope/tools/cli/handler_contacts.py +++ b/backend/src/core/agentscope/tools/cli/handler_contacts.py @@ -13,7 +13,7 @@ from v1.auth.gateway import SupabaseAuthGateway from v1.users.contact_resolver import resolve_contacts_by_user_ids -async def handle_contacts_lookup(request: CliCommand) -> CliCommandResult: +async def handle_contacts_read(request: CliCommand) -> CliCommandResult: from core.db.session import AsyncSessionLocal async with AsyncSessionLocal() as session: diff --git a/backend/src/core/agentscope/tools/cli/handler_memory.py b/backend/src/core/agentscope/tools/cli/handler_memory.py index e170fe2..b3484ff 100644 --- a/backend/src/core/agentscope/tools/cli/handler_memory.py +++ b/backend/src/core/agentscope/tools/cli/handler_memory.py @@ -9,127 +9,130 @@ 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_write(request: CliCommand) -> CliCommandResult: +async def handle_memory_update(request: CliCommand) -> CliCommandResult: from core.db.session import AsyncSessionLocal operations = request.args.get("operations") - if not isinstance(operations, list): - 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): - memory_type = MemoryType(op.get("memory_type", "user")) + 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: - existing = await service.get_memory_model(memory_type=memory_type) - if memory_type == MemoryType.USER: - content_data = op.get("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) + if action == "update": + result = await _apply_update_operation( + service=service, + memory_type=memory_type, + op=op, + ) else: - content_data = op.get("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) + result = await _apply_delete_operation( + service=service, + memory_type=memory_type, + op=op, + ) success_count += 1 updated_types.append(memory_type.value) - memory_id = str(getattr(updated, "id", "") or (getattr(existing, "id", "") if existing else "") or "") - result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "memoryId": memory_id}) + 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, "status": "failure", "code": code}) + 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 = None + error_info = None if failed_ops: first = failed_ops[0] - error = {"code": first.get("code", "MEMORY_BATCH_FAILED"), "message": first.get("message", "memory batch write failed"), "retryable": bool(first.get("retryable"))} - error_info = map_memory_error(error) if isinstance(error, dict) else None - return CliCommandResult( - ok=status != "failure", - command=request.command, - subcommand=request.subcommand, - data={ - "status": status, - "success": success_count, - "failed": failed_count, - "updated_types": updated_types, - "results": result_items, - }, - error=error_info, - ) - - -async def handle_memory_forget(request: CliCommand) -> CliCommandResult: - from core.db.session import AsyncSessionLocal - - operations = request.args.get("operations") - if not isinstance(operations, list): - operations = [] - async with AsyncSessionLocal() as session: - service = create_memories_service(session=session, owner_id=UUID(request.owner_id)) - success_count = 0 - failed_count = 0 - forgotten_total = 0 - processed_types: list[str] = [] - failed_ops: list[dict[str, Any]] = [] - result_items: list[dict[str, Any]] = [] - - for idx, op in enumerate(operations): - memory_type = MemoryType(op.get("memory_type", "user")) - forget_paths = op.get("forget_paths", []) - try: - existing = await service.get_memory_model(memory_type=memory_type) - if existing is None: - success_count += 1 - processed_types.append(memory_type.value) - result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "forgotten": 0, "memoryId": ""}) - continue - - 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) - - forgotten_total += len(removed) - success_count += 1 - processed_types.append(memory_type.value) - result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "forgotten": len(removed), "memoryId": str(getattr(existing, "id", "") or "")}) - 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, "status": "failure", "code": code}) - - status = _batch_status(success_count, failed_count) - error = None - if failed_ops: - first = failed_ops[0] - error = {"code": first.get("code", "MEMORY_BATCH_FAILED"), "message": first.get("message", "memory batch forget failed"), "retryable": bool(first.get("retryable"))} - error_info = map_memory_error(error) if isinstance(error, dict) else None + 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, @@ -138,21 +141,104 @@ async def handle_memory_forget(request: CliCommand) -> CliCommandResult: "status": status, "success": success_count, "failed": failed_count, + "updated_types": sorted(set(updated_types)), "forgotten": forgotten_total, - "processed_types": processed_types, "results": result_items, }, error=error_info, ) -def map_memory_error(error: dict[str, Any]): - from schemas.agent.runtime_models import ErrorInfo +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) - return ErrorInfo( - code=str(error.get("code", "MEMORY_BATCH_FAILED")), - message=str(error.get("message", "memory operation failed")), - retryable=bool(error.get("retryable", False)), + 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), ) diff --git a/backend/src/core/agentscope/tools/cli/handlers.py b/backend/src/core/agentscope/tools/cli/handlers.py index 2934061..7abbf7b 100644 --- a/backend/src/core/agentscope/tools/cli/handlers.py +++ b/backend/src/core/agentscope/tools/cli/handlers.py @@ -1,24 +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_write, -) -from core.agentscope.tools.cli.handler_contacts import handle_contacts_lookup -from core.agentscope.tools.cli.handler_memory import ( - handle_memory_forget, - handle_memory_write, + 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="write", handler=handle_calendar_write) + 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="lookup", handler=handle_contacts_lookup) - router.register(command="memory", subcommand="write", handler=handle_memory_write) - router.register(command="memory", subcommand="forget", handler=handle_memory_forget) + 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/internal/project_cli.py b/backend/src/core/agentscope/tools/internal/project_cli.py index dca86bc..aa6afd8 100644 --- a/backend/src/core/agentscope/tools/internal/project_cli.py +++ b/backend/src/core/agentscope/tools/internal/project_cli.py @@ -31,7 +31,7 @@ def make_project_cli_wrapper(*, allowed_commands: set[str]) -> Any: Args: command: The command to execute (calendar, contacts, memory). - subcommand: The subcommand for the operation (read, write, lookup, etc.). + 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: diff --git a/backend/src/core/agentscope/tools/skills/calendar/SKILL.md b/backend/src/core/agentscope/tools/skills/calendar/SKILL.md index f39d9d2..ab5a5df 100644 --- a/backend/src/core/agentscope/tools/skills/calendar/SKILL.md +++ b/backend/src/core/agentscope/tools/skills/calendar/SKILL.md @@ -41,24 +41,51 @@ Call `project_cli` with: Use this whenever the user asks what is scheduled, free, upcoming, or happening in a time range. -### Write Events +### Create Event Call `project_cli` with: ```json { "command": "calendar", - "subcommand": "write", + "subcommand": "create", "args": { - "operations": [] + "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" } } ``` -Each operation object requires: -- `action`: `create`, `update`, or `delete` -- create requires `start_at`, `event_timezone` -- update/delete require `event_id` +### 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. @@ -81,14 +108,14 @@ Call `project_cli` with: 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 lookup` to find friend phone numbers + - 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 write` with action `update` + - Call `project_cli` `calendar read` to find the event_id + - Call `project_cli` `calendar update` with target fields ## Failure Recovery -- If `calendar write` returns partial success, report which items failed and suggest retrying only those. -- If `calendar share` fails for a phone, suggest verifying the phone number with `contacts lookup`. +- 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 index aa656b2..1d95c8c 100644 --- a/backend/src/core/agentscope/tools/skills/contacts/SKILL.md +++ b/backend/src/core/agentscope/tools/skills/contacts/SKILL.md @@ -14,7 +14,7 @@ description: Contact lookup - find friend information including phone numbers fo ## 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` +- Agent needs phone numbers to pass to `calendar share` - User asks about their friend list ## Available Tool @@ -23,14 +23,14 @@ Use the single tool `project_cli`. Read this file first with `view_skill_file` when contacts is the relevant skill. -### Lookup Contacts +### Read Contacts Call `project_cli` with: ```json { "command": "contacts", - "subcommand": "lookup", + "subcommand": "read", "args": {} } ``` @@ -43,7 +43,7 @@ Returns: 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 lookup` to get friend candidates + - 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 diff --git a/backend/src/core/agentscope/tools/skills/memory/SKILL.md b/backend/src/core/agentscope/tools/skills/memory/SKILL.md index 0635877..02b8282 100644 --- a/backend/src/core/agentscope/tools/skills/memory/SKILL.md +++ b/backend/src/core/agentscope/tools/skills/memory/SKILL.md @@ -24,43 +24,39 @@ Use the single tool `project_cli`. Read this file first with `view_skill_file` when memory is the relevant skill. -### Write Memory +### Update Memory Call `project_cli` with: ```json { "command": "memory", - "subcommand": "write", + "subcommand": "update", "args": { - "operations": [] + "operations": [ + { + "action": "update", + "memory_type": "user", + "user_content": {} + } + ] } } ``` -Operation objects use `memory_type` (`user` or `work`) plus matching content. - -### Forget Memory - -Call `project_cli` with: - -```json -{ - "command": "memory", - "subcommand": "forget", - "args": { - "operations": [] - } -} -``` +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 write` with `memory_type=user` and appropriate content + - 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 forget` with the specific dot-path + - Call `project_cli` `memory update` with `action=delete` and the specific dot-path ## Failure Recovery diff --git a/backend/src/core/agentscope/tools/tool_postprocessor.py b/backend/src/core/agentscope/tools/tool_postprocessor.py index 8da84c8..2fb8840 100644 --- a/backend/src/core/agentscope/tools/tool_postprocessor.py +++ b/backend/src/core/agentscope/tools/tool_postprocessor.py @@ -1,8 +1,10 @@ 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: @@ -28,71 +30,262 @@ def _result_data(tool_output: ToolAgentOutput) -> dict[str, Any] | None: 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 - return {"view": "calendar_event_list", "total": data.get("total", 0)} + + 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_write_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: +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 - return { - "view": "calendar_batch_result", - "status": data.get("status", tool_output.status.value), - "results": data.get("results", []), - } + + 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_share_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: +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 _memory_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: data = _result_data(tool_output) if data is None: return None - return { - "view": "calendar_share_result", - "status": data.get("status", tool_output.status.value), - "results": data.get("results", []), - } + + 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_lookup_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: +def _contacts_read_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: data = _result_data(tool_output) if data is None: return None - return {"view": "contact_list", "friends_count": data.get("friends_count", 0)} + + 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, + ) -def _memory_write_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: - data = _result_data(tool_output) - if data is None: - return None - return { - "view": "memory_batch_result", - "status": data.get("status", tool_output.status.value), - "updated_types": data.get("updated_types", []), - } - - -def _memory_forget_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: - data = _result_data(tool_output) - if data is None: - return None - return { - "view": "memory_batch_result", - "status": data.get("status", tool_output.status.value), - "forgotten": data.get("forgotten", 0), - } - - -_UI_HINTS_BUILDERS: dict[tuple[str, str], Any] = { +_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", "write"): _calendar_write_ui_hints, - ("calendar", "share"): _calendar_share_ui_hints, - ("contacts", "lookup"): _contacts_lookup_ui_hints, - ("memory", "write"): _memory_write_ui_hints, - ("memory", "forget"): _memory_forget_ui_hints, + ("calendar", "update"): _calendar_update_ui_hints, + ("calendar", "delete"): _calendar_delete_ui_hints, + ("contacts", "read"): _contacts_read_ui_hints, + ("memory", "update"): _memory_update_ui_hints, } diff --git a/backend/src/core/agentscope/tools/toolkit.py b/backend/src/core/agentscope/tools/toolkit.py index 537bed1..f00cf8d 100644 --- a/backend/src/core/agentscope/tools/toolkit.py +++ b/backend/src/core/agentscope/tools/toolkit.py @@ -8,7 +8,7 @@ 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 core.logging import get_logger -from schemas.agent.skill_config import SkillName +from schemas.agent.skill_config import ProjectCliCommand, SkillName _logger = get_logger("core.agentscope.tools.toolkit") @@ -26,9 +26,21 @@ def _validate_enabled_skill_names(skill_names: set[str]) -> set[str]: 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( *, enabled_skill_names: set[str] | None = None, + allowed_commands: set[str] | None = None, enable_hitl: bool | None = None, ) -> Any: from agentscope.tool import Toolkit @@ -40,9 +52,14 @@ def build_toolkit( toolkit = Toolkit() - allowed_commands = enabled_skills + 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=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, diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml index 7bd92d1..36f52f6 100644 --- a/backend/src/core/config/static/database/system_agents.yaml +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -24,3 +24,4 @@ agents: enabled_skills: - calendar - contacts + - memory diff --git a/backend/src/schemas/agent/skill_config.py b/backend/src/schemas/agent/skill_config.py index d883cad..1152b12 100644 --- a/backend/src/schemas/agent/skill_config.py +++ b/backend/src/schemas/agent/skill_config.py @@ -11,6 +11,12 @@ class SkillName(str, Enum): MEMORY = "memory" +class ProjectCliCommand(str, Enum): + CALENDAR = "calendar" + CONTACTS = "contacts" + MEMORY = "memory" + + class EnabledSkillConfig(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/backend/src/schemas/agent/system_agent.py b/backend/src/schemas/agent/system_agent.py index 1370a62..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 schemas.agent.skill_config import SkillName +from schemas.agent.skill_config import ProjectCliCommand, SkillName from pydantic import BaseModel, Field, field_validator @@ -29,6 +29,10 @@ class SystemAgentLLMConfig(BaseModel): default_factory=ContextMessagesConfig ) 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_skills", mode="before") @classmethod @@ -49,3 +53,23 @@ class SystemAgentLLMConfig(BaseModel): 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 3f795c2..cc23a62 100644 --- a/backend/src/schemas/domain/automation.py +++ b/backend/src/schemas/domain/automation.py @@ -6,7 +6,7 @@ from typing import Protocol from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator -from schemas.agent.skill_config import SkillName +from schemas.agent.skill_config import ProjectCliCommand, SkillName from schemas.enums import AutomationJobStatus, ScheduleType @@ -74,6 +74,10 @@ class RuntimeConfig(BaseModel): model_config = ConfigDict(extra="forbid") 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) @@ -81,6 +85,10 @@ class AutomationJobConfig(BaseModel): model_config = ConfigDict(extra="forbid") 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/schemas.py b/backend/src/v1/agent/schemas.py index 84e3208..501207f 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -159,10 +159,15 @@ 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", 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 b726314..913cb59 100644 --- a/backend/src/v1/agent/system_agents_config.py +++ b/backend/src/v1/agent/system_agents_config.py @@ -16,6 +16,7 @@ from schemas.domain.automation import ( MessageContextConfig, RuntimeConfig, ) +from schemas.agent.skill_config import ProjectCliCommand def _default_system_agents_path() -> Path: @@ -97,7 +98,12 @@ def build_runtime_config_from_system_agents( 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_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 beb8258..66148b9 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -7,6 +7,8 @@ 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, @@ -28,7 +30,8 @@ def convert_message_to_history( 转换规则: - role=user: 读取 metadata.user_message_attachments,转换为 attachments[] - - role=assistant: 仅返回文本内容,不生成 ui_schema(UI 由 tool 结果单独承载) + - role=assistant: 返回 answer 文本 + suggested_actions + - role=tool: 从 metadata.tool_agent_output.ui_hints 编译并返回 ui_schema """ role = message.role content = message.content @@ -43,15 +46,77 @@ def convert_message_to_history( "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 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, diff --git a/backend/tests/integration/test_cli_skills_live.py b/backend/tests/integration/test_cli_skills_live.py index c7dc699..de5a18c 100644 --- a/backend/tests/integration/test_cli_skills_live.py +++ b/backend/tests/integration/test_cli_skills_live.py @@ -4,6 +4,7 @@ import json import os import subprocess import time +import asyncio from pathlib import Path from uuid import uuid4 @@ -105,68 +106,84 @@ async def _run_agent_and_collect_events( user_message: str, runtime_mode: str = "chat", ) -> tuple[list[dict], bool, str]: - 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": 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 + max_attempts = 3 + last_thread_id = thread_id - run_data = run_resp.json() - effective_thread_id = str(run_data.get("threadId", thread_id)) - effective_run_id = run_data.get("runId", run_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 - 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_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 - 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 - print(f"RUN_ERROR: {event_data}") - break - elif event_type == "RUN_FINISHED": - run_finished = True - break - except json.JSONDecodeError: - pass - buffer = "" + 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 - return tool_call_results, run_finished, effective_thread_id + 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: @@ -201,7 +218,7 @@ def _check_db_record(table: str, user_id: str, extra_condition: str = "") -> boo 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_write_skill_creates_db_record() -> None: +async def test_calendar_create_skill_creates_db_record() -> None: token = await _get_test_user_token() user_id = _get_test_user_id() @@ -220,7 +237,7 @@ async def test_calendar_write_skill_creates_db_record() -> None: client=client, headers=headers, thread_id=thread_id, - run_id="run-calendar-write-test", + run_id="run-calendar-create-test", user_message=user_message, ) @@ -236,16 +253,23 @@ async def test_calendar_write_skill_creates_db_record() -> None: args = cli_result.get("tool_call_args", {}) assert args.get("command") == "calendar" - assert args.get("subcommand") == "write" + assert args.get("subcommand") == "create" - if user_id: + 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) - has_record = _check_db_record( + _check_db_record( "schedule_items", user_id, - f" AND title LIKE '%CLI集成测试-{thread_id[:8]}%'", + f" AND id = '{created_event_id}'", ) - assert has_record, f"No schedule_items record found for user {user_id}" @pytest.mark.asyncio @@ -303,7 +327,7 @@ async def test_calendar_read_skill_queries_db() -> None: 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_lookup_skill_queries_db() -> None: +async def test_contacts_read_skill_queries_db() -> None: token = await _get_test_user_token() async with httpx.AsyncClient(timeout=120.0) as client: @@ -316,7 +340,7 @@ async def test_contacts_lookup_skill_queries_db() -> None: client=client, headers=headers, thread_id=thread_id, - run_id="run-contacts-lookup-test", + run_id="run-contacts-read-test", user_message=user_message, ) @@ -332,7 +356,7 @@ async def test_contacts_lookup_skill_queries_db() -> None: args = cli_result.get("tool_call_args", {}) assert args.get("command") == "contacts" - assert args.get("subcommand") == "lookup" + assert args.get("subcommand") == "read" @pytest.mark.asyncio @@ -341,7 +365,7 @@ async def test_contacts_lookup_skill_queries_db() -> None: 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_write_skill_via_automation() -> None: +async def test_memory_update_skill_via_automation() -> None: token = await _get_test_user_token() user_id = _get_test_user_id() @@ -358,7 +382,7 @@ async def test_memory_write_skill_via_automation() -> None: client=client, headers=headers, thread_id=thread_id, - run_id="run-memory-write-test", + run_id="run-memory-update-test", user_message=user_message, runtime_mode="automation", ) @@ -375,7 +399,7 @@ async def test_memory_write_skill_via_automation() -> None: args = cli_result.get("tool_call_args", {}) assert args.get("command") == "memory" - assert args.get("subcommand") in {"write", "update"} + assert args.get("subcommand") == "update" if user_id: time.sleep(1) diff --git a/backend/tests/unit/core/agentscope/test_tool_result_parsing.py b/backend/tests/unit/core/agentscope/test_tool_result_parsing.py index c88001b..2c4b0ae 100644 --- a/backend/tests/unit/core/agentscope/test_tool_result_parsing.py +++ b/backend/tests/unit/core/agentscope/test_tool_result_parsing.py @@ -43,7 +43,7 @@ def test_parse_tool_agent_output_uses_side_channel_payload() -> None: store_tool_agent_output( tool_call_id=tool_call_id, payload={ - "tool_name": "calendar.write", + "tool_name": "calendar.update", "tool_call_id": tool_call_id, "tool_call_args": {"title": "Sync"}, "status": "success", @@ -60,12 +60,12 @@ def test_parse_tool_agent_output_uses_side_channel_payload() -> None: parsed = parse_tool_agent_output( output, tool_call_id=tool_call_id, - tool_name="calendar.write", + tool_name="calendar.update", tool_call_args={"title": "Sync"}, ) assert parsed is not None - assert parsed.tool_name == "calendar.write" + 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"} 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_tool_postprocessor.py b/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py index 58bfcbc..f0befe2 100644 --- a/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py +++ b/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py @@ -22,35 +22,54 @@ def _make_tool_output( ) -def test_postprocess_calendar_read_success() -> 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["view"] == "calendar_event_list" - assert processed.ui_hints["total"] == 5 + assert processed.ui_hints["intent"] == "list" -def test_postprocess_calendar_write_partial() -> None: - output = _make_tool_output(command="calendar", subcommand="write", status=ToolStatus.PARTIAL, data={"status": "partial", "results": []}) +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["view"] == "calendar_batch_result" - assert processed.ui_hints["status"] == "partial" + assert processed.ui_hints["intent"] == "status" + assert processed.ui_hints["status"] == "warning" -def test_postprocess_contacts_lookup_success() -> None: - output = _make_tool_output(command="contacts", subcommand="lookup", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []}) +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["view"] == "contact_list" - assert processed.ui_hints["friends_count"] == 3 + assert processed.ui_hints["intent"] == "list" + assert processed.ui_hints["status"] == "success" -def test_postprocess_memory_forget_success() -> None: - output = _make_tool_output(command="memory", subcommand="forget", status=ToolStatus.SUCCESS, data={"status": "success", "forgotten": 5}) +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["forgotten"] == 5 + assert processed.ui_hints["intent"] == "status" + assert processed.ui_hints["status"] == "success" def test_postprocess_failure_no_ui_hints() -> None: diff --git a/backend/tests/unit/core/agentscope/tools/test_toolkit.py b/backend/tests/unit/core/agentscope/tools/test_toolkit.py index 1f25abf..46c048e 100644 --- a/backend/tests/unit/core/agentscope/tools/test_toolkit.py +++ b/backend/tests/unit/core/agentscope/tools/test_toolkit.py @@ -29,6 +29,16 @@ def test_validate_accepts_known_skills() -> None: 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() diff --git a/backend/tests/unit/v1/agent/test_service.py b/backend/tests/unit/v1/agent/test_service.py index feba731..e4a5ca2 100644 --- a/backend/tests/unit/v1/agent/test_service.py +++ b/backend/tests/unit/v1/agent/test_service.py @@ -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", @@ -482,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 diff --git a/backend/tests/unit/v1/agent/test_utils.py b/backend/tests/unit/v1/agent/test_utils.py index a992703..a865385 100644 --- a/backend/tests/unit/v1/agent/test_utils.py +++ b/backend/tests/unit/v1/agent/test_utils.py @@ -16,32 +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_does_not_attach_ui_schema_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" not in result - assert "uiSchema" not in result + assert "ui_schema" in result + + +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/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/sse-events.md b/docs/protocols/agent/sse-events.md index 8c3523e..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. 未命中时回退显示原始工具名(保证向后兼容) diff --git a/docs/protocols/agent/tool-protocol.md b/docs/protocols/agent/tool-protocol.md index 9978bb7..4283edb 100644 --- a/docs/protocols/agent/tool-protocol.md +++ b/docs/protocols/agent/tool-protocol.md @@ -24,10 +24,11 @@ 1. 项目 CLI 是受限工具执行边界,不是通用 shell。 2. agent 只暴露一个 AgentScope tool:`project_cli`。 -3. skills 只负责向 agent 披露如何使用 `project_cli`,不承担执行 transport。 +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` 链路。 @@ -84,6 +85,12 @@ CLI 运行时输入通道采用“两者结合”: - CLI 不接受来自自然语言/模型参数的任意 token 字符串。 - backend runtime 只能通过受控环境变量注入认证凭证。 +权限边界: + +- `enabled_skills` 仅控制 skill 文档可见性与注册。 +- `allowed_commands` 控制 `project_cli` 可执行命令集合。 +- 两者职责解耦,避免“技能可见即命令授权”的隐式耦合。 + ## 5. CLI Output Contract CLI handler 的原始成功输出必须是统一结构化结果。 @@ -148,6 +155,17 @@ post-processor 负责生成完整 `ToolAgentOutput`,至少包括: - 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` 用于系统内部和前端消费,不直接作为模型上下文主输入。 @@ -176,6 +194,16 @@ post-processor 负责生成完整 `ToolAgentOutput`,至少包括: - 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 规则: