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:
@@ -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 3 complete
|
||||||
- [x] Phase 4 complete
|
- [x] Phase 4 complete
|
||||||
- [x] Phase 5 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`)
|
||||||
|
|||||||
+96
@@ -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",
|
"branch": "worktree/refactor-tool-cli-skill-ui-schema",
|
||||||
"base_branch": "dev",
|
"base_branch": "dev",
|
||||||
"worktree_path": ".worktrees/refactor-tool-cli-skill-ui-schema",
|
"worktree_path": ".worktrees/refactor-tool-cli-skill-ui-schema",
|
||||||
"current_phase": 2,
|
"current_phase": 5,
|
||||||
"next_action": [
|
"next_action": [
|
||||||
{
|
{
|
||||||
"phase": 1,
|
"phase": 5,
|
||||||
"action": "implement"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 2,
|
|
||||||
"action": "check"
|
"action": "check"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"phase": 3,
|
"phase": 5,
|
||||||
"action": "finish"
|
"action": "implement"
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 4,
|
|
||||||
"action": "create-pr"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"commit": null,
|
"commit": null,
|
||||||
@@ -63,6 +55,14 @@
|
|||||||
{
|
{
|
||||||
"name": "Verify end-to-end agent -> CLI -> UI flow",
|
"name": "Verify end-to-end agent -> CLI -> UI flow",
|
||||||
"status": "pending"
|
"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": [],
|
"children": [],
|
||||||
@@ -79,8 +79,8 @@
|
|||||||
"docs/protocols/ui/data-flow.md",
|
"docs/protocols/ui/data-flow.md",
|
||||||
"docs/protocols/agent/sse-events.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": {
|
"meta": {
|
||||||
"feature_summary": "tool refactor + CLI wrapping + skill guidance + tool-schema rendered UI + worker output simplification"
|
"feature_summary": "tool refactor + CLI wrapping + skill guidance + tool-schema rendered UI + worker output simplification"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,9 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
if (!keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
throw GradleException("Missing apps/android/key.properties for release signing")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ class AgUiEventTypeWire {
|
|||||||
static const stepStarted = 'STEP_STARTED';
|
static const stepStarted = 'STEP_STARTED';
|
||||||
static const stepFinished = 'STEP_FINISHED';
|
static const stepFinished = 'STEP_FINISHED';
|
||||||
static const textMessageEnd = 'TEXT_MESSAGE_END';
|
static const textMessageEnd = 'TEXT_MESSAGE_END';
|
||||||
static const toolCallStart = 'TOOL_CALL_START';
|
|
||||||
static const toolCallArgs = 'TOOL_CALL_ARGS';
|
|
||||||
static const toolCallEnd = 'TOOL_CALL_END';
|
|
||||||
static const toolCallResult = 'TOOL_CALL_RESULT';
|
static const toolCallResult = 'TOOL_CALL_RESULT';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,9 +15,6 @@ enum AgUiEventType {
|
|||||||
stepStarted,
|
stepStarted,
|
||||||
stepFinished,
|
stepFinished,
|
||||||
textMessageEnd,
|
textMessageEnd,
|
||||||
toolCallStart,
|
|
||||||
toolCallArgs,
|
|
||||||
toolCallEnd,
|
|
||||||
toolCallResult,
|
toolCallResult,
|
||||||
unknown,
|
unknown,
|
||||||
}
|
}
|
||||||
@@ -32,9 +26,6 @@ const _wireToTypeMap = {
|
|||||||
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
|
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
|
||||||
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
|
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
|
||||||
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
|
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
|
||||||
AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart,
|
|
||||||
AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs,
|
|
||||||
AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd,
|
|
||||||
AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult,
|
AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,9 +36,6 @@ const _typeToWireMap = {
|
|||||||
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
|
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
|
||||||
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
|
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
|
||||||
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
|
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
|
||||||
AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart,
|
|
||||||
AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs,
|
|
||||||
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
|
|
||||||
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
|
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
|
||||||
AgUiEventType.unknown: '',
|
AgUiEventType.unknown: '',
|
||||||
};
|
};
|
||||||
@@ -74,9 +62,6 @@ abstract class AgUiEvent {
|
|||||||
AgUiEventType.stepStarted => StepStartedEvent.fromJson(json),
|
AgUiEventType.stepStarted => StepStartedEvent.fromJson(json),
|
||||||
AgUiEventType.stepFinished => StepFinishedEvent.fromJson(json),
|
AgUiEventType.stepFinished => StepFinishedEvent.fromJson(json),
|
||||||
AgUiEventType.textMessageEnd => TextMessageEndEvent.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.toolCallResult => ToolCallResultEvent.fromJson(json),
|
||||||
AgUiEventType.unknown => UnknownAgUiEvent(rawJson: json),
|
AgUiEventType.unknown => UnknownAgUiEvent(rawJson: json),
|
||||||
};
|
};
|
||||||
@@ -157,12 +142,14 @@ class TextMessageEndEvent extends AgUiEvent {
|
|||||||
required this.answer,
|
required this.answer,
|
||||||
required this.role,
|
required this.role,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.suggestedActions = const <String>[],
|
||||||
}) : super(type: AgUiEventType.textMessageEnd);
|
}) : super(type: AgUiEventType.textMessageEnd);
|
||||||
|
|
||||||
final String messageId;
|
final String messageId;
|
||||||
final String answer;
|
final String answer;
|
||||||
final String role;
|
final String role;
|
||||||
final String status;
|
final String status;
|
||||||
|
final List<String> suggestedActions;
|
||||||
|
|
||||||
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
|
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
TextMessageEndEvent(
|
TextMessageEndEvent(
|
||||||
@@ -170,53 +157,17 @@ class TextMessageEndEvent extends AgUiEvent {
|
|||||||
answer: _asString(json['answer']),
|
answer: _asString(json['answer']),
|
||||||
role: _asString(json['role'], fallback: 'assistant'),
|
role: _asString(json['role'], fallback: 'assistant'),
|
||||||
status: _asString(json['status'], fallback: 'success'),
|
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 {
|
class ToolCallResultEvent extends AgUiEvent {
|
||||||
ToolCallResultEvent({
|
ToolCallResultEvent({
|
||||||
required this.messageId,
|
required this.messageId,
|
||||||
required this.toolCallId,
|
required this.toolCallId,
|
||||||
required this.toolName,
|
required this.toolName,
|
||||||
required this.resultSummary,
|
this.toolCallArgs,
|
||||||
|
this.result,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.uiSchema,
|
required this.uiSchema,
|
||||||
}) : super(type: AgUiEventType.toolCallResult);
|
}) : super(type: AgUiEventType.toolCallResult);
|
||||||
@@ -224,7 +175,8 @@ class ToolCallResultEvent extends AgUiEvent {
|
|||||||
final String messageId;
|
final String messageId;
|
||||||
final String toolCallId;
|
final String toolCallId;
|
||||||
final String toolName;
|
final String toolName;
|
||||||
final String resultSummary;
|
final Map<String, dynamic>? toolCallArgs;
|
||||||
|
final Object? result;
|
||||||
final String status;
|
final String status;
|
||||||
final Map<String, dynamic>? uiSchema;
|
final Map<String, dynamic>? uiSchema;
|
||||||
|
|
||||||
@@ -233,7 +185,8 @@ class ToolCallResultEvent extends AgUiEvent {
|
|||||||
messageId: _asString(json['messageId']),
|
messageId: _asString(json['messageId']),
|
||||||
toolCallId: _asString(json['tool_call_id']),
|
toolCallId: _asString(json['tool_call_id']),
|
||||||
toolName: _asString(json['tool_name']),
|
toolName: _asString(json['tool_name']),
|
||||||
resultSummary: _asString(json['result']),
|
toolCallArgs: _asMap(json['tool_call_args']),
|
||||||
|
result: json['result'],
|
||||||
status: _asString(json['status'], fallback: 'success'),
|
status: _asString(json['status'], fallback: 'success'),
|
||||||
uiSchema: _asMap(json['ui_schema']),
|
uiSchema: _asMap(json['ui_schema']),
|
||||||
);
|
);
|
||||||
@@ -280,6 +233,7 @@ class HistoryMessage {
|
|||||||
required this.content,
|
required this.content,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
this.attachments = const <HistoryAttachment>[],
|
this.attachments = const <HistoryAttachment>[],
|
||||||
|
this.suggestedActions = const <String>[],
|
||||||
this.uiSchema,
|
this.uiSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,6 +243,7 @@ class HistoryMessage {
|
|||||||
final String content;
|
final String content;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final List<HistoryAttachment> attachments;
|
final List<HistoryAttachment> attachments;
|
||||||
|
final List<String> suggestedActions;
|
||||||
final Map<String, dynamic>? uiSchema;
|
final Map<String, dynamic>? uiSchema;
|
||||||
|
|
||||||
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
|
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
|
||||||
@@ -298,6 +253,7 @@ class HistoryMessage {
|
|||||||
content: _asString(json['content']),
|
content: _asString(json['content']),
|
||||||
timestamp: _parseTimestamp(_asString(json['timestamp'])),
|
timestamp: _parseTimestamp(_asString(json['timestamp'])),
|
||||||
attachments: _parseHistoryAttachments(json['attachments']),
|
attachments: _parseHistoryAttachments(json['attachments']),
|
||||||
|
suggestedActions: _asStringList(json['suggestedActions']),
|
||||||
uiSchema: _asMap(json['ui_schema']),
|
uiSchema: _asMap(json['ui_schema']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -374,3 +330,14 @@ List<HistoryAttachment> _parseHistoryAttachments(Object? value) {
|
|||||||
)
|
)
|
||||||
.toList();
|
.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),
|
.toList(growable: false),
|
||||||
|
'suggestedActions': message.suggestedActions,
|
||||||
'ui_schema': message.uiSchema,
|
'ui_schema': message.uiSchema,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
enum ChatItemType { message, toolCall, toolResult }
|
enum ChatItemType { message, toolResult }
|
||||||
|
|
||||||
enum MessageSender { user, ai }
|
enum MessageSender { user, ai }
|
||||||
|
|
||||||
enum ToolCallStatus { pending, executing, completed, error }
|
|
||||||
|
|
||||||
abstract class ChatListItem {
|
abstract class ChatListItem {
|
||||||
String get id;
|
String get id;
|
||||||
DateTime get timestamp;
|
DateTime get timestamp;
|
||||||
@@ -22,6 +20,7 @@ class TextMessageItem extends ChatListItem {
|
|||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
final bool isLocalEcho;
|
final bool isLocalEcho;
|
||||||
final List<Map<String, dynamic>> attachments;
|
final List<Map<String, dynamic>> attachments;
|
||||||
|
final List<String> suggestedActions;
|
||||||
|
|
||||||
TextMessageItem({
|
TextMessageItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -31,6 +30,7 @@ class TextMessageItem extends ChatListItem {
|
|||||||
this.isStreaming = false,
|
this.isStreaming = false,
|
||||||
this.isLocalEcho = false,
|
this.isLocalEcho = false,
|
||||||
this.attachments = const [],
|
this.attachments = const [],
|
||||||
|
this.suggestedActions = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,6 +44,7 @@ class TextMessageItem extends ChatListItem {
|
|||||||
bool? isStreaming,
|
bool? isStreaming,
|
||||||
bool? isLocalEcho,
|
bool? isLocalEcho,
|
||||||
List<Map<String, dynamic>>? attachments,
|
List<Map<String, dynamic>>? attachments,
|
||||||
|
List<String>? suggestedActions,
|
||||||
}) => TextMessageItem(
|
}) => TextMessageItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
content: content ?? this.content,
|
content: content ?? this.content,
|
||||||
@@ -52,54 +53,7 @@ class TextMessageItem extends ChatListItem {
|
|||||||
isStreaming: isStreaming ?? this.isStreaming,
|
isStreaming: isStreaming ?? this.isStreaming,
|
||||||
isLocalEcho: isLocalEcho ?? this.isLocalEcho,
|
isLocalEcho: isLocalEcho ?? this.isLocalEcho,
|
||||||
attachments: attachments ?? this.attachments,
|
attachments: attachments ?? this.attachments,
|
||||||
);
|
suggestedActions: suggestedActions ?? this.suggestedActions,
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import '../l10n/l10n.dart';
|
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 = [
|
const List<String> automationToolOptions = [
|
||||||
|
'calendar.create',
|
||||||
'calendar.read',
|
'calendar.read',
|
||||||
'calendar.write',
|
'calendar.update',
|
||||||
|
'calendar.delete',
|
||||||
'calendar.share',
|
'calendar.share',
|
||||||
'user.lookup',
|
'contacts.read',
|
||||||
'memory.write',
|
'memory.update',
|
||||||
'memory.forget',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
String localizeToolName(String rawName) {
|
String localizeToolName(String rawName) {
|
||||||
@@ -23,20 +15,21 @@ String localizeToolName(String rawName) {
|
|||||||
if (normalized.isEmpty) {
|
if (normalized.isEmpty) {
|
||||||
return rawName;
|
return rawName;
|
||||||
}
|
}
|
||||||
final canonical = _toolNameAliases[normalized] ?? normalized;
|
switch (normalized) {
|
||||||
switch (canonical) {
|
case 'calendar.create':
|
||||||
|
return L10n.current.toolCalendarWrite;
|
||||||
case 'calendar.read':
|
case 'calendar.read':
|
||||||
return L10n.current.toolCalendarRead;
|
return L10n.current.toolCalendarRead;
|
||||||
case 'calendar.write':
|
case 'calendar.update':
|
||||||
|
return L10n.current.toolCalendarWrite;
|
||||||
|
case 'calendar.delete':
|
||||||
return L10n.current.toolCalendarWrite;
|
return L10n.current.toolCalendarWrite;
|
||||||
case 'calendar.share':
|
case 'calendar.share':
|
||||||
return L10n.current.toolCalendarShare;
|
return L10n.current.toolCalendarShare;
|
||||||
case 'user.lookup':
|
case 'contacts.read':
|
||||||
return L10n.current.toolUserLookup;
|
return L10n.current.toolUserLookup;
|
||||||
case 'memory.write':
|
case 'memory.update':
|
||||||
return L10n.current.toolMemoryWrite;
|
return L10n.current.toolMemoryWrite;
|
||||||
case 'memory.forget':
|
|
||||||
return L10n.current.toolMemoryForget;
|
|
||||||
default:
|
default:
|
||||||
return rawName;
|
return rawName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ String agUiEventLabel(AgUiEventType type) {
|
|||||||
AgUiEventType.stepStarted => l10n.agUiEventStepStarted,
|
AgUiEventType.stepStarted => l10n.agUiEventStepStarted,
|
||||||
AgUiEventType.stepFinished => l10n.agUiEventStepFinished,
|
AgUiEventType.stepFinished => l10n.agUiEventStepFinished,
|
||||||
AgUiEventType.textMessageEnd => l10n.agUiEventTextMessageEnd,
|
AgUiEventType.textMessageEnd => l10n.agUiEventTextMessageEnd,
|
||||||
AgUiEventType.toolCallStart => l10n.agUiEventToolCallStart,
|
|
||||||
AgUiEventType.toolCallArgs => l10n.agUiEventToolCallArgs,
|
|
||||||
AgUiEventType.toolCallEnd => l10n.agUiEventToolCallEnd,
|
|
||||||
AgUiEventType.toolCallResult => l10n.agUiEventToolCallResult,
|
AgUiEventType.toolCallResult => l10n.agUiEventToolCallResult,
|
||||||
AgUiEventType.toolCallError => l10n.agUiEventToolCallError,
|
|
||||||
AgUiEventType.unknown => l10n.agUiEventUnknown,
|
AgUiEventType.unknown => l10n.agUiEventUnknown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,17 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|||||||
bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) {
|
bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) {
|
||||||
final name = event.toolName.trim().toLowerCase();
|
final name = event.toolName.trim().toLowerCase();
|
||||||
final status = event.status.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 false;
|
||||||
}
|
}
|
||||||
return status == 'success' || status == 'partial';
|
return status == 'success' || status == 'partial';
|
||||||
|
|||||||
@@ -24,24 +24,13 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
case AgUiEventType.runFinished:
|
case AgUiEventType.runFinished:
|
||||||
_trackChatCompleted();
|
_trackChatCompleted();
|
||||||
_clearRunMetrics();
|
_clearRunMetrics();
|
||||||
emit(
|
emit(_resetRunState());
|
||||||
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
|
|
||||||
);
|
|
||||||
case AgUiEventType.runError:
|
case AgUiEventType.runError:
|
||||||
final errorEvent = event as RunErrorEvent;
|
final errorEvent = event as RunErrorEvent;
|
||||||
_clearRunMetrics();
|
_clearRunMetrics();
|
||||||
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
||||||
emit(
|
emit(
|
||||||
_resetRunState(
|
_resetRunState(error: isCanceledByUser ? null : errorEvent.message),
|
||||||
error: isCanceledByUser ? null : errorEvent.message,
|
|
||||||
).copyWith(
|
|
||||||
items: _markActiveToolCallsFailed(
|
|
||||||
state.items,
|
|
||||||
reason: isCanceledByUser
|
|
||||||
? L10n.current.chatRunCanceled
|
|
||||||
: L10n.current.chatRunFailed,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
case AgUiEventType.stepStarted:
|
case AgUiEventType.stepStarted:
|
||||||
_handleStepStarted(event as StepStartedEvent);
|
_handleStepStarted(event as StepStartedEvent);
|
||||||
@@ -49,12 +38,6 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
_handleStepFinished(event as StepFinishedEvent);
|
_handleStepFinished(event as StepFinishedEvent);
|
||||||
case AgUiEventType.textMessageEnd:
|
case AgUiEventType.textMessageEnd:
|
||||||
_handleTextMessageEnd(event as TextMessageEndEvent);
|
_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:
|
case AgUiEventType.toolCallResult:
|
||||||
_handleToolCallResult(event as ToolCallResultEvent);
|
_handleToolCallResult(event as ToolCallResultEvent);
|
||||||
case AgUiEventType.unknown:
|
case AgUiEventType.unknown:
|
||||||
@@ -84,12 +67,13 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
state.items,
|
state.items,
|
||||||
event.messageId,
|
event.messageId,
|
||||||
event.answer,
|
event.answer,
|
||||||
|
event.suggestedActions,
|
||||||
timestamp,
|
timestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
items: _removeToolCallItems(items),
|
items: items,
|
||||||
currentMessageId: null,
|
currentMessageId: null,
|
||||||
isWaitingFirstToken: false,
|
isWaitingFirstToken: false,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
@@ -101,6 +85,7 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
List<ChatListItem> items,
|
List<ChatListItem> items,
|
||||||
String messageId,
|
String messageId,
|
||||||
String content,
|
String content,
|
||||||
|
List<String> suggestedActions,
|
||||||
DateTime timestamp,
|
DateTime timestamp,
|
||||||
) {
|
) {
|
||||||
final result = List<ChatListItem>.from(items);
|
final result = List<ChatListItem>.from(items);
|
||||||
@@ -110,7 +95,11 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
final existing = result[index] as TextMessageItem;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,73 +110,18 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
sender: MessageSender.ai,
|
sender: MessageSender.ai,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
suggestedActions: suggestedActions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return result;
|
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) {
|
void _handleToolCallResult(ToolCallResultEvent event) {
|
||||||
if (_shouldRefreshCalendarForTool(event)) {
|
if (_shouldRefreshCalendarForTool(event)) {
|
||||||
unawaited(_refreshCalendarAfterToolMutation());
|
unawaited(_refreshCalendarAfterToolMutation());
|
||||||
}
|
}
|
||||||
final timestamp = DateTime.now();
|
final timestamp = DateTime.now();
|
||||||
final items = state.items.map((item) {
|
final items = List<ChatListItem>.from(state.items);
|
||||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
|
||||||
return item.copyWith(status: ToolCallStatus.completed);
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final uiSchema = event.uiSchema;
|
final uiSchema = event.uiSchema;
|
||||||
if (uiSchema != null) {
|
if (uiSchema != null) {
|
||||||
@@ -219,30 +153,12 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
items.add(uiItem);
|
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) {
|
List<ChatListItem> _convertHistoryMessages(List<HistoryMessage> messages) {
|
||||||
final converted = <ChatListItem>[];
|
final converted = <ChatListItem>[];
|
||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
final normalizedRole = msg.role.toLowerCase();
|
final normalizedRole = msg.role.toLowerCase();
|
||||||
final isUser = normalizedRole == 'user';
|
final isUser = normalizedRole == 'user';
|
||||||
final isTool = normalizedRole == 'tool' || normalizedRole == 'tools';
|
final isTool = normalizedRole == 'tool';
|
||||||
final sender = isUser ? MessageSender.user : MessageSender.ai;
|
final sender = isUser ? MessageSender.user : MessageSender.ai;
|
||||||
final attachments = msg.attachments
|
final attachments = msg.attachments
|
||||||
.map(
|
.map(
|
||||||
@@ -262,11 +178,12 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
sender: sender,
|
sender: sender,
|
||||||
isLocalEcho: false,
|
isLocalEcho: false,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
|
suggestedActions: msg.suggestedActions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isTool && msg.uiSchema != null) {
|
if (isTool && msg.uiSchema != null) {
|
||||||
converted.add(
|
converted.add(
|
||||||
ToolResultItem(
|
ToolResultItem(
|
||||||
id: '${msg.id}-ui',
|
id: '${msg.id}-ui',
|
||||||
|
|||||||
@@ -375,7 +375,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
bottom: _itemSpacing,
|
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:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:social_app/core/chat/chat_list_item.dart';
|
import 'package:social_app/core/chat/chat_list_item.dart';
|
||||||
|
|
||||||
import '../../../../core/l10n/l10n.dart';
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../core/utils/tool_name_localizer.dart';
|
|
||||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||||
import '../../../../shared/widgets/ui_schema/ui_schema_renderer.dart';
|
import '../../../../shared/widgets/ui_schema/ui_schema_renderer.dart';
|
||||||
|
|
||||||
@@ -21,18 +19,28 @@ const _toolResultWidthFactor = 0.88;
|
|||||||
const _iconSize = AppSpacing.xxl;
|
const _iconSize = AppSpacing.xxl;
|
||||||
|
|
||||||
class HomeChatItemRenderer {
|
class HomeChatItemRenderer {
|
||||||
static Widget build(BuildContext context, ChatListItem item) {
|
static Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
ChatListItem item, {
|
||||||
|
ValueChanged<String>? onSuggestedActionTap,
|
||||||
|
}) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case ChatItemType.message:
|
case ChatItemType.message:
|
||||||
return _buildMessageItem(context, item as TextMessageItem);
|
return _buildMessageItem(
|
||||||
case ChatItemType.toolCall:
|
context,
|
||||||
return _buildToolCallItem(context, item as ToolCallItem);
|
item as TextMessageItem,
|
||||||
|
onSuggestedActionTap: onSuggestedActionTap,
|
||||||
|
);
|
||||||
case ChatItemType.toolResult:
|
case ChatItemType.toolResult:
|
||||||
return _buildToolResultItem(context, item as ToolResultItem);
|
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 colorScheme = Theme.of(context).colorScheme;
|
||||||
final isUser = item.sender == MessageSender.user;
|
final isUser = item.sender == MessageSender.user;
|
||||||
final maxMessageWidth =
|
final maxMessageWidth =
|
||||||
@@ -41,6 +49,11 @@ class HomeChatItemRenderer {
|
|||||||
item.attachments,
|
item.attachments,
|
||||||
);
|
);
|
||||||
final hasRenderableAttachments = imageAttachments.isNotEmpty;
|
final hasRenderableAttachments = imageAttachments.isNotEmpty;
|
||||||
|
final suggestedActionTap = onSuggestedActionTap;
|
||||||
|
final shouldRenderSuggestions =
|
||||||
|
!isUser &&
|
||||||
|
item.suggestedActions.isNotEmpty &&
|
||||||
|
suggestedActionTap != null;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: isUser
|
crossAxisAlignment: isUser
|
||||||
? CrossAxisAlignment.end
|
? CrossAxisAlignment.end
|
||||||
@@ -98,6 +111,29 @@ class HomeChatItemRenderer {
|
|||||||
imageAttachments: imageAttachments,
|
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(
|
static Widget _buildToolResultItem(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ToolResultItem item,
|
ToolResultItem item,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
|||||||
String _contextSource = 'latest_chat';
|
String _contextSource = 'latest_chat';
|
||||||
String _contextWindowMode = 'day';
|
String _contextWindowMode = 'day';
|
||||||
int _contextWindowCount = 2;
|
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;
|
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
|||||||
@@ -20,4 +20,18 @@ void main() {
|
|||||||
expect(message.timestamp.isUtc, isFalse);
|
expect(message.timestamp.isUtc, isFalse);
|
||||||
expect(message.timestamp, expected);
|
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(
|
test(
|
||||||
'tool calendar_write success triggers calendar refresh callback',
|
'tool calendar_create success triggers calendar refresh callback',
|
||||||
() async {
|
() async {
|
||||||
final service = _FakeAgUiService();
|
final service = _FakeAgUiService();
|
||||||
var refreshCalls = 0;
|
var refreshCalls = 0;
|
||||||
@@ -250,8 +250,9 @@ void main() {
|
|||||||
ToolCallResultEvent(
|
ToolCallResultEvent(
|
||||||
messageId: 'msg-1',
|
messageId: 'msg-1',
|
||||||
toolCallId: 'call-1',
|
toolCallId: 'call-1',
|
||||||
toolName: 'calendar_write',
|
toolName: 'project_cli',
|
||||||
resultSummary: 'ok',
|
toolCallArgs: const {'command': 'calendar', 'subcommand': 'create'},
|
||||||
|
result: const {'ok': true},
|
||||||
status: 'success',
|
status: 'success',
|
||||||
uiSchema: null,
|
uiSchema: null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from core.agentscope.caches.context_messages_cache import (
|
|||||||
create_context_messages_cache,
|
create_context_messages_cache,
|
||||||
)
|
)
|
||||||
from core.agentscope.events.persistence import MessageRepository, SessionRepository
|
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 core.logging import get_logger
|
||||||
from schemas.agent.forwarded_props import RuntimeMode
|
from schemas.agent.forwarded_props import RuntimeMode
|
||||||
from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus
|
from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus
|
||||||
@@ -339,7 +340,7 @@ class SqlAlchemyEventStore:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
content = tool_output.result
|
content = project_tool_result_text(tool_output.result)
|
||||||
|
|
||||||
locked_session = await session_repo.lock_session_for_update(
|
locked_session = await session_repo.lock_session_for_update(
|
||||||
session_id=session_id
|
session_id=session_id
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ from core.agentscope.utils import (
|
|||||||
finalize_json_response,
|
finalize_json_response,
|
||||||
patch_agentscope_json_repair_compat,
|
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.config.settings import config
|
||||||
from core.db.session import AsyncSessionLocal
|
from core.db.session import AsyncSessionLocal
|
||||||
from models.llm import Llm
|
from models.llm import Llm
|
||||||
@@ -37,7 +42,7 @@ from schemas.agent.runtime_models import (
|
|||||||
RouterAgentOutput,
|
RouterAgentOutput,
|
||||||
WorkerAgentOutputLite,
|
WorkerAgentOutputLite,
|
||||||
)
|
)
|
||||||
from schemas.agent.skill_config import SkillName
|
from schemas.agent.skill_config import ProjectCliCommand, SkillName
|
||||||
from schemas.agent.system_agent import (
|
from schemas.agent.system_agent import (
|
||||||
AgentType,
|
AgentType,
|
||||||
SystemAgentLLMConfig,
|
SystemAgentLLMConfig,
|
||||||
@@ -107,7 +112,8 @@ class AgentScopeRunner:
|
|||||||
agent_type=AgentType.WORKER,
|
agent_type=AgentType.WORKER,
|
||||||
)
|
)
|
||||||
worker_toolkit = self._build_toolkit(
|
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(
|
router_output = await self._execute_router_step(
|
||||||
@@ -174,10 +180,13 @@ class AgentScopeRunner:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
enabled_skills: list[SkillName],
|
enabled_skills: list[SkillName],
|
||||||
|
allowed_commands: list[ProjectCliCommand],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
enabled_skill_names = {str(skill.value) for skill in enabled_skills}
|
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(
|
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(
|
async def _load_stage_config(
|
||||||
@@ -388,56 +397,66 @@ class AgentScopeRunner:
|
|||||||
work_memory: WorkProfileContent | None,
|
work_memory: WorkProfileContent | None,
|
||||||
requires_tool_evidence: bool = False,
|
requires_tool_evidence: bool = False,
|
||||||
) -> StageExecutionResult:
|
) -> StageExecutionResult:
|
||||||
tracking_model = self._build_model(stage_config=stage_config)
|
issuer = create_credential_issuer()
|
||||||
emitter = PipelineStageEmitter(
|
credential = issuer.issue(
|
||||||
pipeline=pipeline,
|
owner_id=str(user_context.id),
|
||||||
session_id=run_input.thread_id,
|
mode=runtime_mode.value,
|
||||||
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(
|
credential_token = set_tool_credential(credential)
|
||||||
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:
|
try:
|
||||||
response_msg = await agent.reply_json(
|
tracking_model = self._build_model(stage_config=stage_config)
|
||||||
input_messages, output_model=worker_output_model
|
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:
|
finally:
|
||||||
async with self._active_agent_lock:
|
reset_tool_credential(credential_token)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_worker_input_messages(
|
def _build_worker_input_messages(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ async def invoke_cli_tool(
|
|||||||
if not isinstance(args, dict):
|
if not isinstance(args, dict):
|
||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
|
tool_call_args = {
|
||||||
|
**tool_call_args,
|
||||||
|
"subcommand": subcommand,
|
||||||
|
"args": args,
|
||||||
|
}
|
||||||
|
|
||||||
if tool_name != "project_cli":
|
if tool_name != "project_cli":
|
||||||
return _build_error(
|
return _build_error(
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
|
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
|
||||||
from core.agentscope.tools.utils.calendar_domain import (
|
from core.agentscope.tools.utils.calendar_domain import (
|
||||||
@@ -12,6 +14,7 @@ from core.agentscope.tools.utils.calendar_domain import (
|
|||||||
parse_iso_datetime,
|
parse_iso_datetime,
|
||||||
schedule_event_to_dict,
|
schedule_event_to_dict,
|
||||||
)
|
)
|
||||||
|
from schemas.agent.runtime_models import ErrorInfo
|
||||||
from schemas.enums import ScheduleItemStatus
|
from schemas.enums import ScheduleItemStatus
|
||||||
from v1.schedule_items.schemas import (
|
from v1.schedule_items.schemas import (
|
||||||
ScheduleItemCreateRequest,
|
ScheduleItemCreateRequest,
|
||||||
@@ -24,83 +27,181 @@ from v1.schedule_items.schemas import (
|
|||||||
async def handle_calendar_read(request: CliCommand) -> CliCommandResult:
|
async def handle_calendar_read(request: CliCommand) -> CliCommandResult:
|
||||||
from core.db.session import AsyncSessionLocal
|
from core.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
start_at = str(request.args.get("start_at", ""))
|
parsed_start, parsed_end, read_error = _resolve_read_range(request)
|
||||||
end_at = str(request.args.get("end_at", ""))
|
if read_error is not None:
|
||||||
parsed_start = parse_iso_datetime(start_at)
|
return _fail(request=request, code="INVALID_ARGUMENT", message=read_error)
|
||||||
parsed_end = parse_iso_datetime(end_at)
|
|
||||||
if parsed_start is None or parsed_end is None:
|
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:
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
service = create_schedule_service(session, UUID(request.owner_id))
|
service = create_schedule_service(session, UUID(request.owner_id))
|
||||||
list_request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end)
|
list_request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end)
|
||||||
items = await service.list_by_date_range(list_request)
|
items = await service.list_by_date_range(list_request)
|
||||||
event_items = [schedule_event_to_dict(item) for item in items]
|
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
|
from core.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
operations = request.args.get("operations")
|
|
||||||
if not isinstance(operations, list):
|
|
||||||
operations = []
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
service = create_schedule_service(session, UUID(request.owner_id))
|
service = create_schedule_service(session, UUID(request.owner_id))
|
||||||
|
try:
|
||||||
success_count = 0
|
result_item = await _create_event(service, request.args)
|
||||||
failed_count = 0
|
event_id = str(result_item.get("eventId") or "")
|
||||||
success_ids: list[str] = []
|
return CliCommandResult(
|
||||||
result_items: list[dict[str, Any]] = []
|
ok=True,
|
||||||
|
command=request.command,
|
||||||
for op in operations:
|
subcommand=request.subcommand,
|
||||||
action = op.get("action")
|
data={
|
||||||
try:
|
"status": "success",
|
||||||
if action == "create":
|
"success": 1,
|
||||||
res = await _create_event(service, op)
|
"failed": 0,
|
||||||
success_count += 1
|
"ids": [event_id] if event_id else [],
|
||||||
success_ids.append(res["eventId"])
|
"results": [result_item],
|
||||||
result_items.append(res)
|
},
|
||||||
elif action == "update":
|
)
|
||||||
res = await _update_event(service, op)
|
except Exception as exc:
|
||||||
success_count += 1
|
code, message, retryable = map_calendar_exception(exc)
|
||||||
success_ids.append(res["eventId"])
|
return CliCommandResult(
|
||||||
result_items.append(res)
|
ok=False,
|
||||||
elif action == "delete":
|
command=request.command,
|
||||||
event_id = op.get("event_id")
|
subcommand=request.subcommand,
|
||||||
if not event_id:
|
data={
|
||||||
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({
|
|
||||||
"status": "failure",
|
"status": "failure",
|
||||||
"eventId": op.get("event_id"),
|
"success": 0,
|
||||||
"code": code,
|
"failed": 1,
|
||||||
"message": message,
|
"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(
|
async def handle_calendar_update(request: CliCommand) -> CliCommandResult:
|
||||||
ok=status != "failure",
|
from core.db.session import AsyncSessionLocal
|
||||||
command=request.command,
|
|
||||||
subcommand=request.subcommand,
|
async with AsyncSessionLocal() as session:
|
||||||
data={
|
service = create_schedule_service(session, UUID(request.owner_id))
|
||||||
"status": status,
|
event_id = str(request.args.get("event_id") or "").strip()
|
||||||
"success": success_count,
|
try:
|
||||||
"failed": failed_count,
|
result_item = await _update_event(service, request.args)
|
||||||
"ids": success_ids,
|
event_id = str(result_item.get("eventId") or event_id)
|
||||||
"results": result_items,
|
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:
|
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()
|
raw_phone = inv.get("phone", "").strip()
|
||||||
normalized_phone = _normalize_phone(raw_phone)
|
normalized_phone = _normalize_phone(raw_phone)
|
||||||
if not normalized_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
|
continue
|
||||||
permission = {
|
permission = {
|
||||||
"permission_view": inv.get("permission_view", True),
|
"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),
|
"permission_invite": inv.get("permission_invite", False),
|
||||||
}
|
}
|
||||||
try:
|
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)
|
invited.append(normalized_phone)
|
||||||
result_items.append({"phone": normalized_phone, "status": "success"})
|
result_items.append({"phone": normalized_phone, "status": "success"})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
code, message, _ = map_calendar_exception(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"])
|
failure_count = len([r for r in result_items if r["status"] == "failure"])
|
||||||
success_count = len(invited)
|
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]:
|
async def _create_event(service: Any, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
start_at = op.get("start_at")
|
start_at = args.get("start_at")
|
||||||
if not start_at:
|
if not isinstance(start_at, str) or not start_at.strip():
|
||||||
raise ValueError("create requires start_at")
|
raise ValueError("create requires start_at")
|
||||||
event_timezone = op.get("event_timezone")
|
event_timezone = args.get("event_timezone")
|
||||||
if not event_timezone:
|
if not isinstance(event_timezone, str) or not event_timezone.strip():
|
||||||
raise ValueError("create requires event_timezone")
|
raise ValueError("create requires event_timezone")
|
||||||
parsed_start = parse_iso_datetime(start_at)
|
parsed_start = parse_iso_datetime(start_at)
|
||||||
if parsed_start is None:
|
if parsed_start is None:
|
||||||
raise ValueError("invalid start_at")
|
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(
|
created = await service.create_agent_generated(
|
||||||
ScheduleItemCreateRequest(
|
ScheduleItemCreateRequest(
|
||||||
title=(op.get("title") or "new event").strip(),
|
title=str(args.get("title") or "new event").strip(),
|
||||||
description=op.get("description", "").strip() or None,
|
description=(str(args.get("description") or "").strip() or None),
|
||||||
start_at=parsed_start,
|
start_at=parsed_start,
|
||||||
end_at=parsed_end,
|
end_at=parsed_end,
|
||||||
timezone=event_timezone.strip(),
|
timezone=event_timezone.strip(),
|
||||||
metadata=build_schedule_metadata(
|
metadata=build_schedule_metadata(
|
||||||
op.get("location"),
|
args.get("location"),
|
||||||
op.get("color"),
|
args.get("color"),
|
||||||
op.get("reminder_minutes"),
|
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]:
|
async def _update_event(service: Any, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
event_id = op.get("event_id")
|
event_id = args.get("event_id")
|
||||||
if not event_id:
|
if not isinstance(event_id, str) or not event_id.strip():
|
||||||
raise ValueError("update requires event_id")
|
raise ValueError("update requires event_id")
|
||||||
|
|
||||||
update_data: dict[str, Any] = {}
|
update_data: dict[str, Any] = {}
|
||||||
if "title" in op:
|
if "title" in args:
|
||||||
update_data["title"] = op["title"].strip()
|
update_data["title"] = str(args.get("title") or "").strip()
|
||||||
if "description" in op:
|
if "description" in args:
|
||||||
update_data["description"] = op["description"].strip()
|
update_data["description"] = str(args.get("description") or "").strip()
|
||||||
if "start_at" in op:
|
if "start_at" in args:
|
||||||
update_data["start_at"] = parse_iso_datetime(op["start_at"])
|
start_value = args.get("start_at")
|
||||||
if "end_at" in op:
|
if not isinstance(start_value, str) or not start_value.strip():
|
||||||
update_data["end_at"] = parse_iso_datetime(op["end_at"])
|
raise ValueError("start_at must be non-empty string")
|
||||||
if "event_timezone" in op:
|
parsed_start = parse_iso_datetime(start_value)
|
||||||
update_data["timezone"] = op["event_timezone"].strip()
|
if parsed_start is None:
|
||||||
if "status" in op:
|
raise ValueError("invalid start_at")
|
||||||
update_data["status"] = ScheduleItemStatus(op["status"])
|
update_data["start_at"] = parsed_start
|
||||||
if any(k in op for k in ("location", "color", "reminder_minutes")):
|
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))
|
existing = await service.get_by_id(UUID(event_id))
|
||||||
update_data["metadata"] = merge_schedule_metadata_for_update(
|
update_data["metadata"] = merge_schedule_metadata_for_update(
|
||||||
existing_metadata=existing.metadata,
|
existing_metadata=existing.metadata,
|
||||||
location=op.get("location"),
|
location=args.get("location"),
|
||||||
color=op.get("color"),
|
color=args.get("color"),
|
||||||
reminder_minutes=op.get("reminder_minutes"),
|
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())
|
changed_fields = sorted(update_data.keys())
|
||||||
updated = await service.update(UUID(event_id), ScheduleItemUpdateRequest.model_validate(update_data))
|
updated = await service.update(
|
||||||
return {"status": "success", "eventId": str(updated.id), "changedFields": changed_fields}
|
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:
|
def _normalize_phone(raw: str) -> str:
|
||||||
@@ -222,7 +377,12 @@ def _normalize_phone(raw: str) -> str:
|
|||||||
phone = f"+{phone}"
|
phone = f"+{phone}"
|
||||||
elif phone.startswith("1") and phone.isdigit():
|
elif phone.startswith("1") and phone.isdigit():
|
||||||
phone = f"+86{phone}"
|
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 ""
|
||||||
return phone
|
return phone
|
||||||
|
|
||||||
@@ -235,9 +395,52 @@ def _batch_status(success: int, failed: int) -> str:
|
|||||||
return "partial"
|
return "partial"
|
||||||
|
|
||||||
|
|
||||||
def _fail(*, request: CliCommand, code: str, message: str) -> CliCommandResult:
|
def _resolve_read_range(
|
||||||
from schemas.agent.runtime_models import ErrorInfo
|
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(
|
return CliCommandResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
command=request.command,
|
command=request.command,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from v1.auth.gateway import SupabaseAuthGateway
|
|||||||
from v1.users.contact_resolver import resolve_contacts_by_user_ids
|
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
|
from core.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
|||||||
@@ -9,127 +9,130 @@ from core.agentscope.tools.utils.memory_domain import (
|
|||||||
create_memories_service,
|
create_memories_service,
|
||||||
map_memory_exception,
|
map_memory_exception,
|
||||||
)
|
)
|
||||||
|
from schemas.agent.runtime_models import ErrorInfo
|
||||||
from schemas.enums import MemoryType
|
from schemas.enums import MemoryType
|
||||||
from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent
|
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
|
from core.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
operations = request.args.get("operations")
|
operations = request.args.get("operations")
|
||||||
if not isinstance(operations, list):
|
if not isinstance(operations, list) or not operations:
|
||||||
operations = []
|
return _invalid_argument(
|
||||||
|
request=request,
|
||||||
|
message="operations must be a non-empty list",
|
||||||
|
)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
service = create_memories_service(session=session, owner_id=UUID(request.owner_id))
|
service = create_memories_service(session=session, owner_id=UUID(request.owner_id))
|
||||||
success_count = 0
|
success_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
updated_types: list[str] = []
|
updated_types: list[str] = []
|
||||||
|
forgotten_total = 0
|
||||||
failed_ops: list[dict[str, Any]] = []
|
failed_ops: list[dict[str, Any]] = []
|
||||||
result_items: list[dict[str, Any]] = []
|
result_items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for idx, op in enumerate(operations):
|
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:
|
try:
|
||||||
existing = await service.get_memory_model(memory_type=memory_type)
|
if action == "update":
|
||||||
if memory_type == MemoryType.USER:
|
result = await _apply_update_operation(
|
||||||
content_data = op.get("user_content", {})
|
service=service,
|
||||||
base = UserMemoryContent.model_validate(existing.content) if existing else UserMemoryContent()
|
memory_type=memory_type,
|
||||||
patch = UserMemoryContent.model_validate(content_data)
|
op=op,
|
||||||
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:
|
else:
|
||||||
content_data = op.get("work_content", {})
|
result = await _apply_delete_operation(
|
||||||
base = WorkProfileContent.model_validate(existing.content) if existing else WorkProfileContent()
|
service=service,
|
||||||
patch = WorkProfileContent.model_validate(content_data)
|
memory_type=memory_type,
|
||||||
merged = _deep_merge_dict(base.model_dump(), patch.model_dump(exclude_unset=True))
|
op=op,
|
||||||
validated = WorkProfileContent.model_validate(merged)
|
)
|
||||||
updated = await service.update_work_memory(content=validated)
|
|
||||||
|
|
||||||
success_count += 1
|
success_count += 1
|
||||||
updated_types.append(memory_type.value)
|
updated_types.append(memory_type.value)
|
||||||
memory_id = str(getattr(updated, "id", "") or (getattr(existing, "id", "") if existing else "") or "")
|
forgotten_total += int(result.get("forgotten") or 0)
|
||||||
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "memoryId": memory_id})
|
result_items.append(
|
||||||
|
{
|
||||||
|
"idx": idx,
|
||||||
|
"memoryType": memory_type.value,
|
||||||
|
"action": action,
|
||||||
|
"status": "success",
|
||||||
|
**result,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
code, message, retryable = map_memory_exception(exc)
|
code, message, retryable = map_memory_exception(exc)
|
||||||
failed_ops.append({"memory_type": memory_type.value, "code": code, "message": message, "retryable": retryable})
|
failed_ops.append(
|
||||||
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "failure", "code": code})
|
{
|
||||||
|
"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)
|
status = _batch_status(success_count, failed_count)
|
||||||
error = None
|
error_info = None
|
||||||
if failed_ops:
|
if failed_ops:
|
||||||
first = failed_ops[0]
|
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 = ErrorInfo(
|
||||||
error_info = map_memory_error(error) if isinstance(error, dict) else None
|
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
|
||||||
return CliCommandResult(
|
message=str(first.get("message") or "memory batch update failed"),
|
||||||
ok=status != "failure",
|
retryable=bool(first.get("retryable")),
|
||||||
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
|
|
||||||
return CliCommandResult(
|
return CliCommandResult(
|
||||||
ok=status != "failure",
|
ok=status != "failure",
|
||||||
command=request.command,
|
command=request.command,
|
||||||
@@ -138,21 +141,104 @@ async def handle_memory_forget(request: CliCommand) -> CliCommandResult:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"success": success_count,
|
"success": success_count,
|
||||||
"failed": failed_count,
|
"failed": failed_count,
|
||||||
|
"updated_types": sorted(set(updated_types)),
|
||||||
"forgotten": forgotten_total,
|
"forgotten": forgotten_total,
|
||||||
"processed_types": processed_types,
|
|
||||||
"results": result_items,
|
"results": result_items,
|
||||||
},
|
},
|
||||||
error=error_info,
|
error=error_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def map_memory_error(error: dict[str, Any]):
|
async def _apply_update_operation(
|
||||||
from schemas.agent.runtime_models import ErrorInfo
|
*,
|
||||||
|
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(
|
memory_id = str(
|
||||||
code=str(error.get("code", "MEMORY_BATCH_FAILED")),
|
getattr(updated, "id", "")
|
||||||
message=str(error.get("message", "memory operation failed")),
|
or (getattr(existing, "id", "") if existing else "")
|
||||||
retryable=bool(error.get("retryable", False)),
|
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 __future__ import annotations
|
||||||
|
|
||||||
from core.agentscope.tools.cli.handler_calendar import (
|
from core.agentscope.tools.cli.handler_calendar import (
|
||||||
|
handle_calendar_create,
|
||||||
|
handle_calendar_delete,
|
||||||
handle_calendar_read,
|
handle_calendar_read,
|
||||||
handle_calendar_share,
|
handle_calendar_share,
|
||||||
handle_calendar_write,
|
handle_calendar_update,
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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
|
from core.agentscope.tools.cli.router import CommandRouter
|
||||||
|
|
||||||
|
|
||||||
def build_router() -> CommandRouter:
|
def build_router() -> CommandRouter:
|
||||||
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="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="calendar", subcommand="share", handler=handle_calendar_share)
|
||||||
router.register(command="contacts", subcommand="lookup", handler=handle_contacts_lookup)
|
router.register(command="contacts", subcommand="read", handler=handle_contacts_read)
|
||||||
router.register(command="memory", subcommand="write", handler=handle_memory_write)
|
router.register(command="memory", subcommand="update", handler=handle_memory_update)
|
||||||
router.register(command="memory", subcommand="forget", handler=handle_memory_forget)
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def make_project_cli_wrapper(*, allowed_commands: set[str]) -> Any:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: The command to execute (calendar, contacts, memory).
|
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.
|
args: Arguments for the command as a JSON object.
|
||||||
|
|
||||||
Returns:
|
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.
|
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:
|
Call `project_cli` with:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"command": "calendar",
|
"command": "calendar",
|
||||||
"subcommand": "write",
|
"subcommand": "create",
|
||||||
"args": {
|
"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:
|
### Update Event
|
||||||
- `action`: `create`, `update`, or `delete`
|
|
||||||
- create requires `start_at`, `event_timezone`
|
Call `project_cli` with:
|
||||||
- update/delete require `event_id`
|
|
||||||
|
```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.
|
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:
|
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 `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
|
- Call `project_cli` `calendar share` with the selected phone
|
||||||
|
|
||||||
2. To update a specific event:
|
2. To update a specific event:
|
||||||
- Call `project_cli` `calendar read` to find the event_id
|
- Call `project_cli` `calendar read` to find the event_id
|
||||||
- Call `project_cli` `calendar write` with action `update`
|
- Call `project_cli` `calendar update` with target fields
|
||||||
|
|
||||||
## Failure Recovery
|
## Failure Recovery
|
||||||
|
|
||||||
- If `calendar write` returns partial success, report which items failed and suggest retrying only those.
|
- 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 lookup`.
|
- 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
|
## When to Use
|
||||||
|
|
||||||
- User wants to share something with a friend but needs their contact info
|
- 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
|
- User asks about their friend list
|
||||||
|
|
||||||
## Available Tool
|
## 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.
|
Read this file first with `view_skill_file` when contacts is the relevant skill.
|
||||||
|
|
||||||
### Lookup Contacts
|
### Read Contacts
|
||||||
|
|
||||||
Call `project_cli` with:
|
Call `project_cli` with:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"command": "contacts",
|
"command": "contacts",
|
||||||
"subcommand": "lookup",
|
"subcommand": "read",
|
||||||
"args": {}
|
"args": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -43,7 +43,7 @@ Returns:
|
|||||||
|
|
||||||
1. To share an event:
|
1. To share an event:
|
||||||
- Call `view_skill_file` with `calendar/SKILL.md` if calendar instructions have not been read in this run
|
- 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
|
- Match user's description to a friend
|
||||||
- Call `project_cli` `calendar share` with the friend's phone
|
- 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.
|
Read this file first with `view_skill_file` when memory is the relevant skill.
|
||||||
|
|
||||||
### Write Memory
|
### Update Memory
|
||||||
|
|
||||||
Call `project_cli` with:
|
Call `project_cli` with:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"command": "memory",
|
"command": "memory",
|
||||||
"subcommand": "write",
|
"subcommand": "update",
|
||||||
"args": {
|
"args": {
|
||||||
"operations": []
|
"operations": [
|
||||||
|
{
|
||||||
|
"action": "update",
|
||||||
|
"memory_type": "user",
|
||||||
|
"user_content": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Operation objects use `memory_type` (`user` or `work`) plus matching content.
|
Operation object fields:
|
||||||
|
- `action`: `update` or `delete`
|
||||||
### Forget Memory
|
- `memory_type`: `user` or `work`
|
||||||
|
- `update` requires matching content payload (`user_content` / `work_content`)
|
||||||
Call `project_cli` with:
|
- `delete` requires `forget_paths`
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"command": "memory",
|
|
||||||
"subcommand": "forget",
|
|
||||||
"args": {
|
|
||||||
"operations": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Composition Patterns
|
## Composition Patterns
|
||||||
|
|
||||||
1. When user says "remember that I prefer morning meetings":
|
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":
|
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
|
## Failure Recovery
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
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:
|
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
|
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:
|
def _calendar_read_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
|
||||||
data = _result_data(tool_output)
|
data = _result_data(tool_output)
|
||||||
if data is None:
|
if data is None:
|
||||||
return 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)
|
data = _result_data(tool_output)
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
return {
|
|
||||||
"view": "calendar_batch_result",
|
success_count = int(data.get("success") or 0)
|
||||||
"status": data.get("status", tool_output.status.value),
|
failed_count = int(data.get("failed") or 0)
|
||||||
"results": data.get("results", []),
|
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)
|
data = _result_data(tool_output)
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
return {
|
|
||||||
"view": "calendar_share_result",
|
success_count = int(data.get("success") or 0)
|
||||||
"status": data.get("status", tool_output.status.value),
|
failed_count = int(data.get("failed") or 0)
|
||||||
"results": data.get("results", []),
|
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)
|
data = _result_data(tool_output)
|
||||||
if data is None:
|
if data is None:
|
||||||
return 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:
|
_UI_HINTS_BUILDERS: dict[tuple[str, str], Callable[[ToolAgentOutput], dict[str, Any] | None]] = {
|
||||||
data = _result_data(tool_output)
|
("calendar", "create"): _calendar_create_ui_hints,
|
||||||
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] = {
|
|
||||||
("calendar", "read"): _calendar_read_ui_hints,
|
("calendar", "read"): _calendar_read_ui_hints,
|
||||||
("calendar", "write"): _calendar_write_ui_hints,
|
("calendar", "update"): _calendar_update_ui_hints,
|
||||||
("calendar", "share"): _calendar_share_ui_hints,
|
("calendar", "delete"): _calendar_delete_ui_hints,
|
||||||
("contacts", "lookup"): _contacts_lookup_ui_hints,
|
("contacts", "read"): _contacts_read_ui_hints,
|
||||||
("memory", "write"): _memory_write_ui_hints,
|
("memory", "update"): _memory_update_ui_hints,
|
||||||
("memory", "forget"): _memory_forget_ui_hints,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.internal.view_skill_file import VIEW_SKILL_FILE_TOOL_NAME
|
||||||
from core.agentscope.tools.tool_middleware import register_tool_middlewares
|
from core.agentscope.tools.tool_middleware import register_tool_middlewares
|
||||||
from core.logging import get_logger
|
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")
|
_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
|
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(
|
def build_toolkit(
|
||||||
*,
|
*,
|
||||||
enabled_skill_names: set[str] | None = None,
|
enabled_skill_names: set[str] | None = None,
|
||||||
|
allowed_commands: set[str] | None = None,
|
||||||
enable_hitl: bool | None = None,
|
enable_hitl: bool | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from agentscope.tool import Toolkit
|
from agentscope.tool import Toolkit
|
||||||
@@ -40,9 +52,14 @@ def build_toolkit(
|
|||||||
|
|
||||||
toolkit = 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(
|
toolkit.register_tool_function(
|
||||||
project_cli_wrapper,
|
project_cli_wrapper,
|
||||||
func_name=PROJECT_CLI_TOOL_NAME,
|
func_name=PROJECT_CLI_TOOL_NAME,
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ agents:
|
|||||||
enabled_skills:
|
enabled_skills:
|
||||||
- calendar
|
- calendar
|
||||||
- contacts
|
- contacts
|
||||||
|
- memory
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class SkillName(str, Enum):
|
|||||||
MEMORY = "memory"
|
MEMORY = "memory"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCliCommand(str, Enum):
|
||||||
|
CALENDAR = "calendar"
|
||||||
|
CONTACTS = "contacts"
|
||||||
|
MEMORY = "memory"
|
||||||
|
|
||||||
|
|
||||||
class EnabledSkillConfig(BaseModel):
|
class EnabledSkillConfig(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from enum import Enum
|
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
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ class SystemAgentLLMConfig(BaseModel):
|
|||||||
default_factory=ContextMessagesConfig
|
default_factory=ContextMessagesConfig
|
||||||
)
|
)
|
||||||
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
|
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
|
||||||
|
allowed_commands: list[ProjectCliCommand] = Field(
|
||||||
|
default_factory=lambda: [command for command in ProjectCliCommand],
|
||||||
|
max_length=32,
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("enabled_skills", mode="before")
|
@field_validator("enabled_skills", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -49,3 +53,23 @@ class SystemAgentLLMConfig(BaseModel):
|
|||||||
if skill not in normalized:
|
if skill not in normalized:
|
||||||
normalized.append(skill)
|
normalized.append(skill)
|
||||||
return normalized
|
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Protocol
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
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
|
from schemas.enums import AutomationJobStatus, ScheduleType
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +74,10 @@ class RuntimeConfig(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
|
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
|
||||||
|
allowed_commands: list[ProjectCliCommand] = Field(
|
||||||
|
default_factory=lambda: [command for command in ProjectCliCommand],
|
||||||
|
max_length=32,
|
||||||
|
)
|
||||||
context: MessageContextConfig = Field(default_factory=MessageContextConfig)
|
context: MessageContextConfig = Field(default_factory=MessageContextConfig)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +85,10 @@ class AutomationJobConfig(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
enabled_skills: list[SkillName] | None = Field(default=None, max_length=32)
|
enabled_skills: list[SkillName] | None = Field(default=None, max_length=32)
|
||||||
|
allowed_commands: list[ProjectCliCommand] | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=32,
|
||||||
|
)
|
||||||
context: MessageContextConfig | None = None
|
context: MessageContextConfig | None = None
|
||||||
input_template: str | None = Field(default=None, min_length=1, max_length=4000)
|
input_template: str | None = Field(default=None, min_length=1, max_length=4000)
|
||||||
schedule: ScheduleConfig | None = None
|
schedule: ScheduleConfig | None = None
|
||||||
|
|||||||
@@ -159,10 +159,15 @@ class HistoryMessage(BaseModel):
|
|||||||
|
|
||||||
id: str = Field(description="Message UUID")
|
id: str = Field(description="Message UUID")
|
||||||
seq: int = Field(description="Message sequence number")
|
seq: int = Field(description="Message sequence number")
|
||||||
role: Literal["user", "assistant"] = Field(
|
role: Literal["user", "assistant", "tool"] = Field(
|
||||||
description="Message role: user | assistant"
|
description="Message role: user | assistant | tool"
|
||||||
)
|
)
|
||||||
content: str = Field(description="Message text content")
|
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(
|
attachments: list[HistoryMessageAttachment] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Temporary signed URLs for user-attached images",
|
description="Temporary signed URLs for user-attached images",
|
||||||
|
|||||||
@@ -530,8 +530,6 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
for msg_dict in raw_messages:
|
for msg_dict in raw_messages:
|
||||||
msg = AgentChatMessage.model_validate(msg_dict)
|
msg = AgentChatMessage.model_validate(msg_dict)
|
||||||
if msg.role == "tool":
|
|
||||||
continue
|
|
||||||
|
|
||||||
signed_urls: dict[str, str] = {}
|
signed_urls: dict[str, str] = {}
|
||||||
attachments = extract_user_message_attachments(msg.metadata)
|
attachments = extract_user_message_attachments(msg.metadata)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from schemas.domain.automation import (
|
|||||||
MessageContextConfig,
|
MessageContextConfig,
|
||||||
RuntimeConfig,
|
RuntimeConfig,
|
||||||
)
|
)
|
||||||
|
from schemas.agent.skill_config import ProjectCliCommand
|
||||||
|
|
||||||
|
|
||||||
def _default_system_agents_path() -> Path:
|
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:
|
if worker_config and worker_config.enabled_skills:
|
||||||
enabled_skills = list(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(
|
return RuntimeConfig(
|
||||||
enabled_skills=enabled_skills,
|
enabled_skills=enabled_skills,
|
||||||
|
allowed_commands=allowed_commands,
|
||||||
context=context_cfg,
|
context=context_cfg,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
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 (
|
from schemas.domain.chat_message import (
|
||||||
AgentChatMessage,
|
AgentChatMessage,
|
||||||
AgentChatMessageMetadata,
|
AgentChatMessageMetadata,
|
||||||
@@ -28,7 +30,8 @@ def convert_message_to_history(
|
|||||||
|
|
||||||
转换规则:
|
转换规则:
|
||||||
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
|
- 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
|
role = message.role
|
||||||
content = message.content
|
content = message.content
|
||||||
@@ -43,15 +46,77 @@ def convert_message_to_history(
|
|||||||
"seq": message.seq,
|
"seq": message.seq,
|
||||||
"role": role,
|
"role": role,
|
||||||
"content": content,
|
"content": content,
|
||||||
|
"suggestedActions": _extract_suggested_actions(metadata),
|
||||||
"timestamp": message.timestamp.isoformat(),
|
"timestamp": message.timestamp.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui_schema = _extract_tool_ui_schema(metadata)
|
||||||
|
if ui_schema is not None:
|
||||||
|
result["ui_schema"] = ui_schema
|
||||||
|
|
||||||
if attachments:
|
if attachments:
|
||||||
result["attachments"] = attachments
|
result["attachments"] = attachments
|
||||||
|
|
||||||
return result
|
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(
|
def _convert_user_attachments(
|
||||||
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
||||||
get_signed_url_fn: Callable[[dict[str, str]], str] | None,
|
get_signed_url_fn: Callable[[dict[str, str]], str] | None,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -105,68 +106,84 @@ async def _run_agent_and_collect_events(
|
|||||||
user_message: str,
|
user_message: str,
|
||||||
runtime_mode: str = "chat",
|
runtime_mode: str = "chat",
|
||||||
) -> tuple[list[dict], bool, str]:
|
) -> tuple[list[dict], bool, str]:
|
||||||
run_resp = await client.post(
|
max_attempts = 3
|
||||||
f"{BASE_URL}/api/v1/agent/runs",
|
last_thread_id = thread_id
|
||||||
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
|
|
||||||
|
|
||||||
run_data = run_resp.json()
|
for attempt in range(max_attempts):
|
||||||
effective_thread_id = str(run_data.get("threadId", thread_id))
|
attempt_run_id = run_id if attempt == 0 else f"{run_id}-retry-{attempt}"
|
||||||
effective_run_id = run_data.get("runId", run_id)
|
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}"
|
run_data = run_resp.json()
|
||||||
tool_call_results: list[dict] = []
|
effective_thread_id = str(run_data.get("threadId", thread_id))
|
||||||
run_finished = False
|
effective_run_id = run_data.get("runId", attempt_run_id)
|
||||||
|
last_thread_id = effective_thread_id
|
||||||
|
|
||||||
async with client.stream(
|
events_url = f"{BASE_URL}/api/v1/agent/runs/{effective_thread_id}/events?runId={effective_run_id}"
|
||||||
"GET", events_url, headers=headers, timeout=120.0
|
tool_call_results: list[dict] = []
|
||||||
) as sse_resp:
|
run_finished = False
|
||||||
if sse_resp.status_code != 200:
|
run_error_code: str | None = None
|
||||||
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 = ""
|
|
||||||
|
|
||||||
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:
|
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",
|
os.getenv("CLI_SKILLS_LIVE_TEST") != "1",
|
||||||
reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test",
|
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()
|
token = await _get_test_user_token()
|
||||||
user_id = _get_test_user_id()
|
user_id = _get_test_user_id()
|
||||||
|
|
||||||
@@ -220,7 +237,7 @@ async def test_calendar_write_skill_creates_db_record() -> None:
|
|||||||
client=client,
|
client=client,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
run_id="run-calendar-write-test",
|
run_id="run-calendar-create-test",
|
||||||
user_message=user_message,
|
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", {})
|
args = cli_result.get("tool_call_args", {})
|
||||||
assert args.get("command") == "calendar"
|
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)
|
time.sleep(1)
|
||||||
has_record = _check_db_record(
|
_check_db_record(
|
||||||
"schedule_items",
|
"schedule_items",
|
||||||
user_id,
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -303,7 +327,7 @@ async def test_calendar_read_skill_queries_db() -> None:
|
|||||||
os.getenv("CLI_SKILLS_LIVE_TEST") != "1",
|
os.getenv("CLI_SKILLS_LIVE_TEST") != "1",
|
||||||
reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test",
|
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()
|
token = await _get_test_user_token()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
@@ -316,7 +340,7 @@ async def test_contacts_lookup_skill_queries_db() -> None:
|
|||||||
client=client,
|
client=client,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
run_id="run-contacts-lookup-test",
|
run_id="run-contacts-read-test",
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +356,7 @@ async def test_contacts_lookup_skill_queries_db() -> None:
|
|||||||
|
|
||||||
args = cli_result.get("tool_call_args", {})
|
args = cli_result.get("tool_call_args", {})
|
||||||
assert args.get("command") == "contacts"
|
assert args.get("command") == "contacts"
|
||||||
assert args.get("subcommand") == "lookup"
|
assert args.get("subcommand") == "read"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -341,7 +365,7 @@ async def test_contacts_lookup_skill_queries_db() -> None:
|
|||||||
os.getenv("CLI_SKILLS_LIVE_TEST") != "1",
|
os.getenv("CLI_SKILLS_LIVE_TEST") != "1",
|
||||||
reason="set CLI_SKILLS_LIVE_TEST=1 to run live CLI + skills integration test",
|
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()
|
token = await _get_test_user_token()
|
||||||
user_id = _get_test_user_id()
|
user_id = _get_test_user_id()
|
||||||
|
|
||||||
@@ -358,7 +382,7 @@ async def test_memory_write_skill_via_automation() -> None:
|
|||||||
client=client,
|
client=client,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
run_id="run-memory-write-test",
|
run_id="run-memory-update-test",
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
runtime_mode="automation",
|
runtime_mode="automation",
|
||||||
)
|
)
|
||||||
@@ -375,7 +399,7 @@ async def test_memory_write_skill_via_automation() -> None:
|
|||||||
|
|
||||||
args = cli_result.get("tool_call_args", {})
|
args = cli_result.get("tool_call_args", {})
|
||||||
assert args.get("command") == "memory"
|
assert args.get("command") == "memory"
|
||||||
assert args.get("subcommand") in {"write", "update"}
|
assert args.get("subcommand") == "update"
|
||||||
|
|
||||||
if user_id:
|
if user_id:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def test_parse_tool_agent_output_uses_side_channel_payload() -> None:
|
|||||||
store_tool_agent_output(
|
store_tool_agent_output(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
payload={
|
payload={
|
||||||
"tool_name": "calendar.write",
|
"tool_name": "calendar.update",
|
||||||
"tool_call_id": tool_call_id,
|
"tool_call_id": tool_call_id,
|
||||||
"tool_call_args": {"title": "Sync"},
|
"tool_call_args": {"title": "Sync"},
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -60,12 +60,12 @@ def test_parse_tool_agent_output_uses_side_channel_payload() -> None:
|
|||||||
parsed = parse_tool_agent_output(
|
parsed = parse_tool_agent_output(
|
||||||
output,
|
output,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
tool_name="calendar.write",
|
tool_name="calendar.update",
|
||||||
tool_call_args={"title": "Sync"},
|
tool_call_args={"title": "Sync"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert parsed is not None
|
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.tool_call_id == tool_call_id
|
||||||
assert parsed.result == {"status": "success", "event": {"id": "evt_1"}}
|
assert parsed.result == {"status": "success", "event": {"id": "evt_1"}}
|
||||||
assert parsed.ui_hints == {"view": "calendar_event_created"}
|
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": []})
|
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5, "items": []})
|
||||||
processed = postprocess_tool_output(output)
|
processed = postprocess_tool_output(output)
|
||||||
assert processed.ui_hints is not None
|
assert processed.ui_hints is not None
|
||||||
assert processed.ui_hints["view"] == "calendar_event_list"
|
assert processed.ui_hints["intent"] == "list"
|
||||||
assert processed.ui_hints["total"] == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_postprocess_calendar_write_partial() -> None:
|
def test_postprocess_calendar_create_partial() -> None:
|
||||||
output = _make_tool_output(command="calendar", subcommand="write", status=ToolStatus.PARTIAL, data={"status": "partial", "results": []})
|
output = _make_tool_output(command="calendar", subcommand="create", status=ToolStatus.PARTIAL, data={"status": "partial", "success": 1, "failed": 1, "results": []})
|
||||||
processed = postprocess_tool_output(output)
|
processed = postprocess_tool_output(output)
|
||||||
assert processed.ui_hints is not None
|
assert processed.ui_hints is not None
|
||||||
assert processed.ui_hints["view"] == "calendar_batch_result"
|
assert processed.ui_hints["intent"] == "status"
|
||||||
assert processed.ui_hints["status"] == "partial"
|
assert processed.ui_hints["status"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
def test_postprocess_contacts_lookup_success() -> None:
|
def test_postprocess_contacts_read_has_ui_hints() -> None:
|
||||||
output = _make_tool_output(command="contacts", subcommand="lookup", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []})
|
output = _make_tool_output(command="contacts", subcommand="read", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []})
|
||||||
processed = postprocess_tool_output(output)
|
processed = postprocess_tool_output(output)
|
||||||
assert processed.ui_hints is not None
|
assert processed.ui_hints is not None
|
||||||
assert processed.ui_hints["view"] == "contact_list"
|
assert processed.ui_hints["intent"] == "list"
|
||||||
assert processed.ui_hints["friends_count"] == 3
|
assert processed.ui_hints["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
def test_postprocess_memory_forget_success() -> None:
|
def test_postprocess_memory_update_has_ui_hints() -> None:
|
||||||
output = _make_tool_output(command="memory", subcommand="forget", status=ToolStatus.SUCCESS, data={"status": "success", "forgotten": 5})
|
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)
|
processed = postprocess_tool_output(output)
|
||||||
assert processed.ui_hints is not None
|
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:
|
def test_postprocess_failure_no_ui_hints() -> None:
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ def test_validate_accepts_known_skills() -> None:
|
|||||||
assert result == {"calendar", "contacts"}
|
assert result == {"calendar", "contacts"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_unknown_allowed_command() -> None:
|
||||||
|
from core.agentscope.tools.toolkit import _validate_allowed_commands
|
||||||
|
|
||||||
|
try:
|
||||||
|
_validate_allowed_commands({"calendar", "unknown_command"})
|
||||||
|
assert False, "should have raised"
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "unknown_command" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
def test_build_toolkit_registers_project_cli() -> None:
|
def test_build_toolkit_registers_project_cli() -> None:
|
||||||
toolkit = build_toolkit()
|
toolkit = build_toolkit()
|
||||||
schemas = toolkit.get_json_schemas()
|
schemas = toolkit.get_json_schemas()
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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):
|
class _HistoryRepository(_FakeRepository):
|
||||||
async def get_history_day(
|
async def get_history_day(
|
||||||
self,
|
self,
|
||||||
@@ -446,7 +446,20 @@ async def test_get_history_snapshot_filters_out_tool_messages() -> None:
|
|||||||
"tool_name": "calendar_read",
|
"tool_name": "calendar_read",
|
||||||
"tool_call_id": "call-1",
|
"tool_call_id": "call-1",
|
||||||
"status": "success",
|
"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",
|
"timestamp": "2026-03-17T09:00:01Z",
|
||||||
@@ -482,7 +495,12 @@ async def test_get_history_snapshot_filters_out_tool_messages() -> None:
|
|||||||
current_user=_user(),
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -16,32 +16,43 @@ class _FakeMessage:
|
|||||||
self.timestamp = datetime.now(timezone.utc)
|
self.timestamp = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def test_convert_message_to_history_does_not_attach_ui_schema_for_tool_message() -> (
|
def test_convert_message_to_history_attaches_ui_schema_for_tool_message() -> None:
|
||||||
None
|
|
||||||
):
|
|
||||||
message = _FakeMessage(
|
message = _FakeMessage(
|
||||||
role="tool",
|
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={
|
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]
|
result = convert_message_to_history(message) # type: ignore[arg-type]
|
||||||
|
|
||||||
assert "ui_schema" not in result
|
assert "ui_schema" in result
|
||||||
assert "uiSchema" not 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:
|
def test_convert_message_to_history_returns_multiple_user_attachments() -> None:
|
||||||
|
|||||||
@@ -161,19 +161,28 @@ run 过滤语义:
|
|||||||
messages: Array<{
|
messages: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
seq: number;
|
seq: number;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
|
suggestedActions?: string[];
|
||||||
attachments?: Array<{ // user 附件签名链接列表
|
attachments?: Array<{ // user 附件签名链接列表
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
url: string;
|
url: string;
|
||||||
}>;
|
}>;
|
||||||
ui_schema?: object | null; // assistant 的编译后 UI
|
ui_schema?: object | null; // tool 的编译后 UI
|
||||||
timestamp: string; // ISO-8601
|
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 对象。
|
||||||
|
|
||||||
可见性说明:
|
可见性说明:
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ data: <json>
|
|||||||
|
|
||||||
### 3.3 Tool 事件
|
### 3.3 Tool 事件
|
||||||
|
|
||||||
|
前端渲染约束(当前实现):
|
||||||
|
|
||||||
|
- tool UI 渲染仅消费 `TOOL_CALL_RESULT.ui_schema`。
|
||||||
|
- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` 仅作为执行观测事件保留,前端主聊天流不渲染中间态卡片。
|
||||||
|
|
||||||
#### `TOOL_CALL_START`
|
#### `TOOL_CALL_START`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -184,18 +189,22 @@ data: <json>
|
|||||||
"tool_call_id": "...",
|
"tool_call_id": "...",
|
||||||
"tool_call_args": {},
|
"tool_call_args": {},
|
||||||
"status": "success" | "failure" | "partial",
|
"status": "success" | "failure" | "partial",
|
||||||
"result": "...",
|
"result": {},
|
||||||
"error": null
|
"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_id` 必须与同次调用的 `TOOL_CALL_START/ARGS/END.toolCallId` 一致,并在每次工具调用中保持唯一。
|
||||||
- `tool_call_args` 仅表示输入参数快照。
|
- `tool_call_args` 仅表示输入参数快照。
|
||||||
- `result` 仅表示执行输出事实,不重复 `tool_call_args` 已包含的输入参数。
|
- `result` 仅表示执行输出事实,不重复 `tool_call_args` 已包含的输入参数。
|
||||||
|
- `ui_schema` 为可渲染 UI 线缆格式;其源数据来自 `metadata.tool_agent_output.ui_hints`。
|
||||||
|
|
||||||
#### 3.3.1 tool 名称展示规范(前端本地化)
|
#### 3.3.1 tool 名称展示规范(前端本地化)
|
||||||
|
|
||||||
@@ -206,21 +215,22 @@ SSE 协议中的工具名字段保持后端原样,不做服务端翻译:
|
|||||||
|
|
||||||
前端展示层统一通过工具名本地化映射进行中文渲染,要求兼容两类命名风格:
|
前端展示层统一通过工具名本地化映射进行中文渲染,要求兼容两类命名风格:
|
||||||
|
|
||||||
- dot 风格:`memory.write`、`calendar.read`
|
- dot 风格:`memory.update`、`calendar.read`
|
||||||
- snake 风格:`memory_write`、`calendar_read`
|
- snake 风格:`memory_update`、`calendar_read`
|
||||||
|
|
||||||
当前规范映射(canonical -> 中文)如下:
|
当前规范映射(canonical -> 中文)如下:
|
||||||
|
|
||||||
- `calendar.read` -> `读取日程`
|
- `calendar.read` -> `读取日程`
|
||||||
- `calendar.write` -> `写入日程`
|
- `calendar.create` -> `创建日程`
|
||||||
|
- `calendar.update` -> `更新日程`
|
||||||
|
- `calendar.delete` -> `删除日程`
|
||||||
- `calendar.share` -> `共享日程`
|
- `calendar.share` -> `共享日程`
|
||||||
- `user.lookup` -> `查找联系人`
|
- `contacts.read` -> `读取联系人`
|
||||||
- `memory.write` -> `写入记忆`
|
- `memory.update` -> `更新记忆`
|
||||||
- `memory.forget` -> `清理记忆`
|
|
||||||
|
|
||||||
兼容策略:
|
兼容策略:
|
||||||
|
|
||||||
1. 优先按 alias 归一化(例如 `memory_write` -> `memory.write`)
|
1. 优先按 alias 归一化(例如 `memory_update` -> `memory.update`)
|
||||||
2. 命中 canonical 映射后展示中文
|
2. 命中 canonical 映射后展示中文
|
||||||
3. 未命中时回退显示原始工具名(保证向后兼容)
|
3. 未命中时回退显示原始工具名(保证向后兼容)
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,11 @@
|
|||||||
|
|
||||||
1. 项目 CLI 是受限工具执行边界,不是通用 shell。
|
1. 项目 CLI 是受限工具执行边界,不是通用 shell。
|
||||||
2. agent 只暴露一个 AgentScope tool:`project_cli`。
|
2. agent 只暴露一个 AgentScope tool:`project_cli`。
|
||||||
3. skills 只负责向 agent 披露如何使用 `project_cli`,不承担执行 transport。
|
3. skills 只负责向 agent 披露如何使用 `project_cli`,不承担执行 transport 或权限决策。
|
||||||
4. Router 是 CLI 的唯一命令分发核心,只允许白名单 `command + subcommand`。
|
4. Router 是 CLI 的唯一命令分发核心,只允许白名单 `command + subcommand`。
|
||||||
5. 每个 CLI 子命令绑定 Python handler。
|
5. 每个 CLI 子命令绑定 Python handler。
|
||||||
6. handler 只能调用允许的内部能力,不开放任意系统命令执行。
|
6. handler 只能调用允许的内部能力,不开放任意系统命令执行。
|
||||||
|
6.1 `project_cli` 命令权限由 runtime `allowed_commands` 与 CLI router 白名单共同约束,不能由 skills 启用状态隐式放开。
|
||||||
7. `ToolAgentOutput.result` 是 canonical machine-oriented tool result。
|
7. `ToolAgentOutput.result` 是 canonical machine-oriented tool result。
|
||||||
8. `ToolResponse` 不承载完整 `ToolAgentOutput`,只承载给 agent 使用的文本投影。
|
8. `ToolResponse` 不承载完整 `ToolAgentOutput`,只承载给 agent 使用的文本投影。
|
||||||
9. tool UI 只来自 `ToolAgentOutput.ui_hints`,不再经过 worker `ui_hints -> ui_schema` 链路。
|
9. tool UI 只来自 `ToolAgentOutput.ui_hints`,不再经过 worker `ui_hints -> ui_schema` 链路。
|
||||||
@@ -84,6 +85,12 @@ CLI 运行时输入通道采用“两者结合”:
|
|||||||
- CLI 不接受来自自然语言/模型参数的任意 token 字符串。
|
- CLI 不接受来自自然语言/模型参数的任意 token 字符串。
|
||||||
- backend runtime 只能通过受控环境变量注入认证凭证。
|
- backend runtime 只能通过受控环境变量注入认证凭证。
|
||||||
|
|
||||||
|
权限边界:
|
||||||
|
|
||||||
|
- `enabled_skills` 仅控制 skill 文档可见性与注册。
|
||||||
|
- `allowed_commands` 控制 `project_cli` 可执行命令集合。
|
||||||
|
- 两者职责解耦,避免“技能可见即命令授权”的隐式耦合。
|
||||||
|
|
||||||
## 5. CLI Output Contract
|
## 5. CLI Output Contract
|
||||||
|
|
||||||
CLI handler 的原始成功输出必须是统一结构化结果。
|
CLI handler 的原始成功输出必须是统一结构化结果。
|
||||||
@@ -148,6 +155,17 @@ post-processor 负责生成完整 `ToolAgentOutput`,至少包括:
|
|||||||
- tool 失败时 `error` 必须为结构化对象。
|
- tool 失败时 `error` 必须为结构化对象。
|
||||||
- `status` 必须是 `success | failure | partial`。
|
- `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
|
## 8. ToolAgentOutput Contract
|
||||||
|
|
||||||
`ToolAgentOutput` 用于系统内部和前端消费,不直接作为模型上下文主输入。
|
`ToolAgentOutput` 用于系统内部和前端消费,不直接作为模型上下文主输入。
|
||||||
@@ -176,6 +194,16 @@ post-processor 负责生成完整 `ToolAgentOutput`,至少包括:
|
|||||||
- tool message 的 UI 恢复从 `metadata.tool_agent_output.ui_hints` 读取,编译为 `ui_schema` 后返回。
|
- tool message 的 UI 恢复从 `metadata.tool_agent_output.ui_hints` 读取,编译为 `ui_schema` 后返回。
|
||||||
- tool message `content` 仍是 `result` 的 JSON 文本投影。
|
- 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
|
## 10. SSE Contract
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
|
|||||||
Reference in New Issue
Block a user