refactor: clean CLI taxonomy — canonical subcommands, merged memory.update, no aliases

- calendar: split write → create/read/update/delete/share
- contacts: rename lookup → read
- memory: merge write+forget → update (unified action field in operations)
- Remove all alias/normalization logic from adapter and handlers
- Update tool_postprocessor ui_hints builders to canonical keys
- Remove frontend legacy TOOL_CALL_START/ARGS/END events and ToolCallItem
- Update SKILL.md files and protocol docs
- Update tests and settings screens
This commit is contained in:
qzl
2026-04-23 12:12:41 +08:00
parent 91077a933d
commit 19e273a9e6
48 changed files with 1578 additions and 811 deletions
@@ -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`)
@@ -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
@@ -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"
}
}
}
+2 -3
View File
@@ -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",
+23 -56
View File
@@ -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 <String>[],
}) : super(type: AgUiEventType.textMessageEnd);
final String messageId;
final String answer;
final String role;
final String status;
final List<String> suggestedActions;
factory TextMessageEndEvent.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> args;
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>? toolCallArgs;
final Object? result;
final String status;
final Map<String, dynamic>? 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 <HistoryAttachment>[],
this.suggestedActions = const <String>[],
this.uiSchema,
});
@@ -289,6 +243,7 @@ class HistoryMessage {
final String content;
final DateTime timestamp;
final List<HistoryAttachment> attachments;
final List<String> suggestedActions;
final Map<String, dynamic>? uiSchema;
factory HistoryMessage.fromJson(Map<String, dynamic> 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<HistoryAttachment> _parseHistoryAttachments(Object? value) {
)
.toList();
}
List<String> _asStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList();
}
@@ -91,6 +91,7 @@ class ChatHistoryRepository extends CachedRepository<HistorySnapshot> {
},
)
.toList(growable: false),
'suggestedActions': message.suggestedActions,
'ui_schema': message.uiSchema,
};
}
+5 -51
View File
@@ -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<Map<String, dynamic>> attachments;
final List<String> 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<Map<String, dynamic>>? attachments,
List<String>? 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<String, dynamic> 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<String, dynamic>? 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,
);
}
+13 -20
View File
@@ -1,21 +1,13 @@
import '../l10n/l10n.dart';
const Map<String, String> _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<String> 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;
}
@@ -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,
};
}
@@ -257,7 +257,17 @@ class ChatBloc extends Cubit<ChatState> 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';
@@ -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<ChatListItem> items,
String messageId,
String content,
List<String> suggestedActions,
DateTime timestamp,
) {
final result = List<ChatListItem>.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<ChatListItem>.from(state.items);
final uiSchema = event.uiSchema;
if (uiSchema != null) {
@@ -219,30 +153,12 @@ extension _ChatBlocEvents on ChatBloc {
items.add(uiItem);
}
List<ChatListItem> _removeToolCallItems(List<ChatListItem> items) {
return items.where((item) => item is! ToolCallItem).toList();
}
List<ChatListItem> _markActiveToolCallsFailed(
List<ChatListItem> 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<ChatListItem> _convertHistoryMessages(List<HistoryMessage> messages) {
final converted = <ChatListItem>[];
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',
@@ -375,7 +375,15 @@ class _HomeScreenState extends State<HomeScreen>
padding: const EdgeInsets.only(
bottom: _itemSpacing,
),
child: HomeChatItemRenderer.build(context, item),
child: HomeChatItemRenderer.build(
context,
item,
onSuggestedActionTap: (suggestion) =>
_sendMessage(
context,
overrideContent: suggestion,
),
),
),
],
);
@@ -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<String>? 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<String>? 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,
@@ -45,7 +45,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
String _contextSource = 'latest_chat';
String _contextWindowMode = 'day';
int _contextWindowCount = 2;
final Set<String> _selectedTools = <String>{'memory.write', 'memory.forget'};
final Set<String> _selectedTools = <String>{'memory.update'};
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
+14
View File
@@ -20,4 +20,18 @@ void main() {
expect(message.timestamp.isUtc, isFalse);
expect(message.timestamp, expected);
});
test('history message parses suggested actions', () {
final raw = <String, dynamic>{
'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, ['查看日程', '创建会议']);
});
}
@@ -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,
),
+2 -1
View File
@@ -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
+68 -49
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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:
@@ -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),
)
@@ -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
@@ -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:
@@ -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": "<uuid>",
"title": "Updated title"
}
}
```
### Delete Event
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "delete",
"args": {
"event_id": "<uuid>"
}
}
```
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`.
@@ -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
@@ -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
@@ -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,
}
+20 -3
View File
@@ -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,
@@ -24,3 +24,4 @@ agents:
enabled_skills:
- calendar
- contacts
- memory
@@ -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")
+25 -1
View File
@@ -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
+9 -1
View File
@@ -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
+7 -2
View File
@@ -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",
-2
View File
@@ -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)
@@ -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,
)
+66 -1
View File
@@ -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,
@@ -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)
@@ -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"}
@@ -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"
@@ -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
@@ -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:
@@ -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()
+21 -3
View File
@@ -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
+29 -18
View File
@@ -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:
+12 -3
View File
@@ -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 对象。
可见性说明:
+20 -10
View File
@@ -127,6 +127,11 @@ data: <json>
### 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: <json>
"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. 未命中时回退显示原始工具名(保证向后兼容)
+29 -1
View File
@@ -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
规则: