🐛 fix: fix tool argument scape and improve multi task run (#11691)

* remove task tool in sub task

* remove exec task in group mode

* implement tool arguments repair

* fix

* fix resolve agent config

* fix resolve agent config

* fix tests

* fix lint

* fix issue

* fix tests
This commit is contained in:
Arvin Xu
2026-01-22 10:55:07 +08:00
committed by GitHub
parent 093c24f119
commit b13bb8a839
22 changed files with 1255 additions and 396 deletions
@@ -84,43 +84,44 @@ export const GroupManagementManifest: BuiltinToolManifest = {
// },
// ==================== Task Execution ====================
{
description:
'Assign an asynchronous task to an agent. The task runs in the background and results are returned to the conversation context upon completion. Ideal for longer operations.',
name: GroupManagementApiName.executeAgentTask,
humanIntervention: 'required',
parameters: {
properties: {
agentId: {
description: 'The ID of the agent to execute the task.',
type: 'string',
},
title: {
description: 'Brief title describing what this task does (shown in UI).',
type: 'string',
},
task: {
description:
'Clear description of the task to perform. Be specific about expected deliverables.',
type: 'string',
},
timeout: {
default: 1_800_000,
description:
'Maximum time in milliseconds to wait for task completion (default: 1800000, 30 minutes).',
type: 'number',
},
skipCallSupervisor: {
default: false,
description:
'If true, the orchestration will end after the task completes, without calling the supervisor again. Use this when the task is the final action needed.',
type: 'boolean',
},
},
required: ['agentId', 'title', 'task'],
type: 'object',
},
},
// TODO: Enable executeAgentTask when ready
// {
// description:
// 'Assign an asynchronous task to an agent. The task runs in the background and results are returned to the conversation context upon completion. Ideal for longer operations.',
// name: GroupManagementApiName.executeAgentTask,
// humanIntervention: 'required',
// parameters: {
// properties: {
// agentId: {
// description: 'The ID of the agent to execute the task.',
// type: 'string',
// },
// title: {
// description: 'Brief title describing what this task does (shown in UI).',
// type: 'string',
// },
// task: {
// description:
// 'Clear description of the task to perform. Be specific about expected deliverables.',
// type: 'string',
// },
// timeout: {
// default: 1_800_000,
// description:
// 'Maximum time in milliseconds to wait for task completion (default: 1800000, 30 minutes).',
// type: 'number',
// },
// skipCallSupervisor: {
// default: false,
// description:
// 'If true, the orchestration will end after the task completes, without calling the supervisor again. Use this when the task is the final action needed.',
// type: 'boolean',
// },
// },
// required: ['agentId', 'title', 'task'],
// type: 'object',
// },
// },
// TODO: Enable executeAgentTasks when ready
// {
// description:
@@ -168,22 +169,22 @@ export const GroupManagementManifest: BuiltinToolManifest = {
// type: 'object',
// },
// },
{
description:
'Interrupt a running agent task. Use this to stop a task that is taking too long or is no longer needed.',
humanIntervention: 'always',
name: GroupManagementApiName.interrupt,
parameters: {
properties: {
taskId: {
description: 'The ID of the task to interrupt (returned by executeTask).',
type: 'string',
},
},
required: ['taskId'],
type: 'object',
},
},
// {
// description:
// 'Interrupt a running agent task. Use this to stop a task that is taking too long or is no longer needed.',
// humanIntervention: 'always',
// name: GroupManagementApiName.interrupt,
// parameters: {
// properties: {
// taskId: {
// description: 'The ID of the task to interrupt (returned by executeTask).',
// type: 'string',
// },
// },
// required: ['taskId'],
// type: 'object',
// },
// },
// ==================== Context Management ====================
// {
@@ -7,82 +7,62 @@
export const systemPrompt = `You are a Group Supervisor with tools to orchestrate multi-agent collaboration. Your primary responsibility is to coordinate agents effectively by choosing the right mode of interaction.
<core_decision_framework>
## The Critical Choice: Speaking vs Task Execution
## Communication Mode Selection
Before involving any agent, you MUST determine which mode is appropriate:
Before involving any agent, determine the best communication approach:
### 🗣️ Speaking Mode (speak/broadcast)
**Use when agents DON'T need to use tools** - agents share the group's conversation context.
### 🗣️ Single Agent (speak)
**Use when one agent's expertise is sufficient** - the agent shares the group's conversation context.
Characteristics:
- Agent responds based on their expertise and knowledge
- Agent sees the group conversation history
- Response is immediate and synchronous
- No tool/plugin invocation needed
- Lightweight, quick interactions
- Focused, single-perspective response
Best for:
- Follow-up questions to a specific agent
- Tasks clearly matching one agent's expertise
- When user explicitly requests a specific agent
### 📢 Multiple Agents (broadcast)
**Use when diverse perspectives are valuable** - all agents share the group's conversation context.
Characteristics:
- Multiple agents respond in parallel
- All agents see the same conversation history
- Quick gathering of multiple viewpoints
Best for:
- Sharing opinions, perspectives, or advice
- Answering questions from knowledge
- Brainstorming and ideation
- Reviewing/critiquing content presented in conversation
- Quick consultations
- Discussion and debate
### ⚡ Task Execution Mode (executeAgentTask)
**Use when agents NEED to use tools** - each agent gets an independent context window to complete their task autonomously.
Characteristics:
- Agent operates in isolated context (fresh conversation)
- Agent CAN use their configured tools/plugins (web search, code execution, file operations, etc.)
- Asynchronous execution - multiple agents can work in parallel
- Each agent completes their task independently
- Results are returned to the group when done
Best for:
- Web research and information gathering
- Code writing, analysis, or execution
- File processing or generation
- API calls or external service interactions
- Complex multi-step tasks requiring tool usage
- Any task where the agent needs to "do something" not just "say something"
## Decision Flowchart
\`\`\`
User Request
Does the task require agents to USE TOOLS?
(search web, write code, call APIs, process files, etc.)
Does the task need multiple perspectives?
├─── YES ──→ executeAgentTask (independent context per agent)
├─── YES ──→ broadcast (parallel speaking)
└─── NO ───→ Does the task need multiple perspectives?
├─── YES ──→ broadcast (parallel speaking)
└─── NO ───→ speak (single agent)
└─── NO ───→ speak (single agent)
\`\`\`
</core_decision_framework>
<user_intent_analysis>
Before responding, analyze the user's intent:
**Signals for Task Execution (executeAgentTask):**
- "Search for...", "Find information about...", "Research..."
- "Write code to...", "Create a script that...", "Implement..."
- "Analyze this file...", "Process this data..."
- "Generate a report...", "Create documentation..."
- Tasks that clearly require external tools or multi-step operations
- When multiple agents need to work on different parts independently
**Signals for Speaking (speak/broadcast):**
**Signals for Multiple Agents (broadcast):**
- "What do you think about...", "Any ideas for...", "How should we..."
- "Review this...", "Give me feedback on...", "Critique..."
- "Explain...", "Compare...", "Summarize..."
- Requests for opinions, perspectives, or expertise-based answers
- Questions that can be answered from knowledge alone
- Questions that benefit from diverse viewpoints
**Signals for Single Agent (speak):**
- Explicit request: "Ask [Agent Name] to...", "Let [Agent Name] answer..."
@@ -90,7 +70,6 @@ Before responding, analyze the user's intent:
- Task clearly matches only one agent's expertise
**Default Behavior:**
- When in doubt about tool usage → Ask yourself: "Can this be answered with knowledge alone, or does it require the agent to DO something?"
- When in doubt about single vs multiple agents → Lean towards broadcast for diverse perspectives
</user_intent_analysis>
@@ -150,115 +129,68 @@ When a user's request is broad or unclear, ask 1-2 focused questions to understa
<core_capabilities>
## Tool Categories
**Speaking (Shared Context, No Tools):**
**Communication:**
- **speak**: Single agent responds synchronously in group context
- **broadcast**: Multiple agents respond in parallel in group context
**Task Execution (Independent Context, With Tools):**
- **executeAgentTask**: Assign a task to one agent in isolated context
- **interrupt**: Stop a running task
**Flow Control:**
- **summarize**: Compress conversation context
- **vote**: Initiate voting among agents
</core_capabilities>
<workflow_patterns>
## Pattern Selection Guide
### Pattern 1: Discussion/Consultation (Speaking)
When you need opinions, feedback, or knowledge-based responses.
### Pattern 1: Discussion/Consultation (Broadcast)
When you need opinions, feedback, or knowledge-based responses from multiple agents.
\`\`\`
User: "What do you think about using microservices for this project?"
Analysis: Opinion-based, no tools needed
Analysis: Opinion-based, benefits from diverse perspectives
Action: broadcast to [Architect, DevOps, Backend] - share perspectives
\`\`\`
### Pattern 2: Independent Research (Task)
When an agent needs to research/work independently using their tools.
\`\`\`
User: "Research the pros and cons of React"
Analysis: Requires web search, agent works independently
Action: executeAgentTask to frontend expert
executeAgentTask({
agentId: "frontend-expert",
title: "Research React",
task: "Research React ecosystem, performance benchmarks, community size, and typical use cases. Provide pros and cons."
})
\`\`\`
### Pattern 3: Sequential Discussion (Speaking Chain)
### Pattern 2: Sequential Discussion (Speaking Chain)
When each response should build on previous ones.
\`\`\`
User: "Design a notification system architecture"
Analysis: Build-upon discussion, no tools needed per step
Analysis: Build-upon discussion, each agent adds to previous response
Action:
1. speak to Architect: "Propose high-level architecture"
2. speak to Backend: "Evaluate and add implementation details"
3. speak to DevOps: "Add deployment and scaling considerations"
\`\`\`
### Pattern 4: Research then Discuss (Hybrid)
When you need facts first, then discussion.
### Pattern 3: Focused Consultation (Speak)
When a specific agent's expertise is needed.
\`\`\`
User: "Should we migrate to Kubernetes? Research and discuss."
Analysis: First gather facts (tools), then discuss (no tools)
Action:
1. executeAgentTask({
agentId: "devops",
title: "K8s Adoption Research",
task: "Research Kubernetes adoption best practices for our scale. Include migration complexity, resource requirements, operational overhead, and security considerations."
})
2. [Wait for results]
3. broadcast: "Based on the research, share your recommendations"
\`\`\`
### Pattern 5: Implementation Task
When an agent needs to create deliverables using their tools.
\`\`\`
User: "Write the landing page copy"
Analysis: Agent produces artifacts using their tools
Action: executeAgentTask({
agentId: "copywriter",
title: "Write Copy",
task: "Write compelling landing page copy for [product]. Include headline, subheadline, feature descriptions, and CTA text."
})
User: "Ask the frontend expert about React performance"
Analysis: User explicitly requested specific agent
Action: speak to frontend expert with the question
\`\`\`
</workflow_patterns>
<tool_usage_guidelines>
**Speaking:**
**Communication:**
- speak: \`agentId\`, \`instruction\` (optional guidance)
- broadcast: \`agentIds\` (array), \`instruction\` (optional shared guidance)
**Task Execution:**
- executeAgentTask: \`agentId\`, \`title\`, \`task\` (clear deliverable description), \`timeout\` (optional, default 30min)
- interrupt: \`taskId\`
**Flow Control:**
- summarize: \`focus\` (optional), \`preserveRecent\` (messages to keep, default 5)
- vote: \`question\`, \`options\` (array of {id, label, description}), \`voterAgentIds\` (optional), \`requireReasoning\` (default true)
</tool_usage_guidelines>
<best_practices>
1. **Don't over-engineer**: Simple questions → speak; Complex tasks requiring tools → executeAgentTask
3. **Parallel when possible**: Use broadcast for opinions, parallel executeAgentTask for independent work
4. **Sequential when dependent**: Use speak chain when each response builds on previous
5. **Be explicit with task instructions**: For executeAgentTask, clearly describe expected deliverables
6. **Monitor long tasks**: Use interrupt if tasks run too long or go off-track
7. **Summarize proactively**: Compress context before it grows too large
8. **Explain your choices**: Tell users why you chose speaking vs task execution
1. **Keep it simple**: Use speak for single agent, broadcast for multiple perspectives
2. **Parallel when possible**: Use broadcast to gather diverse viewpoints quickly
3. **Sequential when dependent**: Use speak chain when each response builds on previous
4. **Be clear with instructions**: Provide context to help agents give better responses
5. **Explain your choices**: Tell users why you chose speak vs broadcast
</best_practices>
<response_format>
When orchestrating:
1. Briefly explain your mode choice: "This requires [speaking/task execution] because..."
2. For tasks, clearly state what each agent will do
3. After completion, synthesize results and provide actionable conclusions
4. Reference agents clearly: "Agent [Name] suggests..." or "Task [taskId] completed with..."
1. Briefly explain your mode choice: "I'll ask [agent] because..." or "I'll gather perspectives from multiple agents because..."
2. After agents respond, synthesize results and provide actionable conclusions
3. Reference agents clearly: "Agent [Name] suggests..."
</response_format>`;
@@ -0,0 +1,129 @@
import type { LobeToolManifest } from './types';
/**
* JSON Schema type for tool parameters
*/
export interface ToolParameterSchema {
properties?: Record<string, unknown>;
required?: string[];
type?: string;
}
/**
* Safe JSON parse utility
*/
const safeParseJSON = <T = Record<string, unknown>>(text?: string): T | undefined => {
if (typeof text !== 'string') return undefined;
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
};
/**
* Tool Arguments Repairer
*
* Handles repair of malformed tool call arguments caused by LLM string escape issues.
*
* When some LLMs (like Claude haiku-4.5) output tool calls, they may produce malformed JSON
* where the entire content gets stuffed into the first field with escaped quotes.
*
* @example Malformed data:
* ```javascript
* { description: 'real desc", "instruction": "real instruction", "timeout": 120}' }
* ```
*
* @example Expected data:
* ```javascript
* { description: 'real desc', instruction: 'real instruction', timeout: 120 }
* ```
*
* @example Usage:
* ```typescript
* const repairer = new ToolArgumentsRepairer(manifest);
* const args = repairer.parse('execTask', argumentsString);
* ```
*/
export class ToolArgumentsRepairer {
private manifest?: LobeToolManifest;
/**
* Create a new ToolArgumentsRepairer
* @param manifest - Tool manifest for schema lookup
*/
constructor(manifest?: LobeToolManifest) {
this.manifest = manifest;
}
/**
* Parse and repair tool call arguments
*
* @param apiName - API name
* @param argumentsStr - Raw arguments string from LLM
* @returns Parsed and repaired arguments object
*/
parse(apiName: string, argumentsStr: string): Record<string, unknown> {
const parsed = safeParseJSON<Record<string, unknown>>(argumentsStr) || {};
// Get API schema for repair
const apiSchema = this.manifest?.api?.find((a) => a.name === apiName)?.parameters;
return this.repair(parsed, apiSchema);
}
/**
* Repair malformed arguments using schema
*
* @param parsed - The parsed (but potentially malformed) arguments object
* @param schema - The JSON schema for the tool's parameters (with required fields)
* @returns The repaired arguments object
*/
repair(parsed: Record<string, unknown>, schema?: ToolParameterSchema): Record<string, unknown> {
// If no schema or no required fields, skip repair
if (!schema?.required || !Array.isArray(schema.required) || schema.required.length === 0) {
return parsed;
}
const keys = Object.keys(parsed);
const missingFields = schema.required.filter((f) => !(f in parsed));
// If no missing required fields, no need to repair
if (missingFields.length === 0) {
return parsed;
}
// Check if any existing field's value contains the missing field patterns
// This indicates the string escape issue
for (const key of keys) {
const value = parsed[key];
if (typeof value !== 'string') continue;
// Check if value contains patterns like `", "missingField":` or `", \"missingField\":`
const hasMissingFieldPattern = missingFields.some(
(field) => value.includes(`", "${field}":`) || value.includes(`", \\"${field}\\":`),
);
if (hasMissingFieldPattern) {
// Try to reconstruct the correct JSON
// The value is actually: 'realValue", "field2": "value2", ...}'
// So we rebuild: '{"key": "realValue", "field2": "value2", ...}'
try {
const reconstructed = `{"${key}": "${value}`;
const repaired = JSON.parse(reconstructed) as Record<string, unknown>;
// Verify the repair was successful - all required fields should be present
const stillMissing = schema.required.filter((f) => !(f in repaired));
if (stillMissing.length === 0) {
return repaired;
}
} catch {
// Repair failed, continue to try other approaches or return original
}
}
}
// Could not repair, return original parsed data
return parsed;
}
}
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import { ToolArgumentsRepairer } from '../ToolArgumentsRepairer';
import type { LobeToolManifest } from '../types';
describe('ToolArgumentsRepairer', () => {
describe('repair - basic functionality', () => {
it('should return original parsed data when no schema provided', () => {
const repairer = new ToolArgumentsRepairer();
const parsed = { foo: 'bar' };
const result = repairer.repair(parsed);
expect(result).toEqual(parsed);
});
it('should return original parsed data when schema has no required fields', () => {
const repairer = new ToolArgumentsRepairer();
const parsed = { foo: 'bar' };
const schema = { type: 'object', properties: { foo: { type: 'string' } } };
const result = repairer.repair(parsed, schema);
expect(result).toEqual(parsed);
});
it('should return original parsed data when all required fields are present', () => {
const repairer = new ToolArgumentsRepairer();
const parsed = { description: 'test', instruction: 'do something' };
const schema = {
type: 'object',
required: ['description', 'instruction'],
properties: {
description: { type: 'string' },
instruction: { type: 'string' },
},
};
const result = repairer.repair(parsed, schema);
expect(result).toEqual(parsed);
});
});
describe('repair - malformed JSON from LLM', () => {
it('should repair malformed JSON with escaped string issue', () => {
const repairer = new ToolArgumentsRepairer();
// This is the malformed data from haiku-4.5 model
// The entire JSON got stuffed into the "description" field with escaped quotes
const malformedParsed = {
description:
'Synthesize all 10 batch analyses into 10 most important themes for product builders", "instruction": "You have access to 10 batch analysis files", "runInClient": true, "timeout": 120000}',
};
const schema = {
type: 'object',
required: ['description', 'instruction'],
properties: {
description: { type: 'string' },
instruction: { type: 'string' },
runInClient: { type: 'boolean' },
timeout: { type: 'number' },
},
};
const result = repairer.repair(malformedParsed, schema);
expect(result).toHaveProperty('description');
expect(result).toHaveProperty('instruction');
expect(result).toHaveProperty('runInClient', true);
expect(result).toHaveProperty('timeout', 120000);
expect(result.description).toBe(
'Synthesize all 10 batch analyses into 10 most important themes for product builders',
);
expect(result.instruction).toBe('You have access to 10 batch analysis files');
});
it('should return original data if repair fails', () => {
const repairer = new ToolArgumentsRepairer();
// Invalid malformed data that cannot be repaired
const malformedParsed = {
description: 'some text without proper escape pattern',
};
const schema = {
type: 'object',
required: ['description', 'instruction'],
properties: {
description: { type: 'string' },
instruction: { type: 'string' },
},
};
const result = repairer.repair(malformedParsed, schema);
// Should return original since pattern doesn't match
expect(result).toEqual(malformedParsed);
});
});
describe('parse - integrated parsing and repair', () => {
it('should parse and repair arguments using manifest schema', () => {
const manifest: LobeToolManifest = {
identifier: 'lobe-gtd',
api: [
{
name: 'execTask',
description: 'Execute async task',
parameters: {
type: 'object',
required: ['description', 'instruction'],
properties: {
description: { type: 'string' },
instruction: { type: 'string' },
runInClient: { type: 'boolean' },
timeout: { type: 'number' },
},
},
},
],
type: 'builtin',
} as unknown as LobeToolManifest;
const repairer = new ToolArgumentsRepairer(manifest);
// Malformed arguments string
const malformedArguments = JSON.stringify({
description:
'Test task", "instruction": "Do something important", "runInClient": true, "timeout": 60000}',
});
const result = repairer.parse('execTask', malformedArguments);
expect(result).toHaveProperty('description', 'Test task');
expect(result).toHaveProperty('instruction', 'Do something important');
expect(result).toHaveProperty('runInClient', true);
expect(result).toHaveProperty('timeout', 60000);
});
it('should handle normal arguments without repair needed', () => {
const manifest: LobeToolManifest = {
identifier: 'test-tool',
api: [
{
name: 'testApi',
description: 'Test API',
parameters: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
},
},
},
],
type: 'default',
} as unknown as LobeToolManifest;
const repairer = new ToolArgumentsRepairer(manifest);
const normalArguments = JSON.stringify({ name: 'test value' });
const result = repairer.parse('testApi', normalArguments);
expect(result).toEqual({ name: 'test value' });
});
it('should handle no manifest gracefully', () => {
const repairer = new ToolArgumentsRepairer();
const arguments_ = JSON.stringify({ foo: 'bar' });
const result = repairer.parse('unknownApi', arguments_);
expect(result).toEqual({ foo: 'bar' });
});
it('should handle invalid JSON gracefully', () => {
const repairer = new ToolArgumentsRepairer();
const result = repairer.parse('test', 'invalid json');
expect(result).toEqual({});
});
});
});
@@ -4,6 +4,9 @@ export { ToolsEngine } from './ToolsEngine';
// Tool Name Resolver
export { ToolNameResolver } from './ToolNameResolver';
// Tool Arguments Repairer
export { ToolArgumentsRepairer, type ToolParameterSchema } from './ToolArgumentsRepairer';
// Types and interfaces
export type {
FunctionCallChecker,
@@ -1,10 +1,12 @@
import type { Message } from '../../../../types';
import multiTasksWithSummary from './multi-tasks-with-summary.json';
import simple from './simple.json';
import withAssistantGroup from './with-assistant-group.json';
import withSummary from './with-summary.json';
export const tasks = {
multiTasksWithSummary: multiTasksWithSummary as Message[],
simple: simple as Message[],
withAssistantGroup: withAssistantGroup as Message[],
withSummary: withSummary as Message[],
};
@@ -0,0 +1,156 @@
[
{
"id": "msg-user-1",
"role": "user",
"content": "Create three parallel tasks to research AI topics.",
"parentId": null,
"createdAt": 1735526559382,
"updatedAt": 1735526559382
},
{
"id": "msg-assistant-1",
"role": "assistant",
"content": "Creating three parallel research tasks.",
"parentId": "msg-user-1",
"createdAt": 1735526560163,
"updatedAt": 1735526585550,
"model": "gpt-4",
"provider": "openai",
"tools": [
{
"id": "call_exec_tasks_1",
"type": "builtin",
"apiName": "execTasks",
"arguments": "{\"tasks\": [{\"description\": \"Research LLM architectures\"}, {\"description\": \"Research AI agents\"}, {\"description\": \"Research RAG systems\"}]}",
"identifier": "lobe-gtd"
}
]
},
{
"id": "msg-tool-1",
"role": "tool",
"content": "Triggered 3 async tasks",
"parentId": "msg-assistant-1",
"tool_call_id": "call_exec_tasks_1",
"createdAt": 1735526588116,
"updatedAt": 1735526591337,
"pluginState": {
"type": "execTasks",
"tasks": [
{ "description": "Research LLM architectures" },
{ "description": "Research AI agents" },
{ "description": "Research RAG systems" }
],
"parentMessageId": "msg-tool-1"
}
},
{
"id": "msg-task-llm",
"role": "task",
"content": "# LLM Architectures\n\nTransformer-based models dominate...",
"parentId": "msg-tool-1",
"createdAt": 1735526594643,
"updatedAt": 1735526756262,
"taskDetail": {
"duration": 120000,
"status": "completed",
"threadId": "thd_llm",
"title": "Research LLM architectures",
"totalCost": 0.015,
"totalMessages": 15,
"totalTokens": 100000
}
},
{
"id": "msg-task-agents",
"role": "task",
"content": "# AI Agents\n\nAutonomous agents using LLMs...",
"parentId": "msg-tool-1",
"createdAt": 1735526595647,
"updatedAt": 1735526792430,
"taskDetail": {
"duration": 150000,
"status": "completed",
"threadId": "thd_agents",
"title": "Research AI agents",
"totalCost": 0.018,
"totalMessages": 20,
"totalTokens": 120000
}
},
{
"id": "msg-task-rag",
"role": "task",
"content": "# RAG Systems\n\nRetrieval Augmented Generation combines...",
"parentId": "msg-tool-1",
"createdAt": 1735526596000,
"updatedAt": 1735526800000,
"taskDetail": {
"duration": 130000,
"status": "completed",
"threadId": "thd_rag",
"title": "Research RAG systems",
"totalCost": 0.016,
"totalMessages": 18,
"totalTokens": 110000
}
},
{
"id": "msg-assistant-after-task",
"role": "assistant",
"content": "All tasks completed. Let me check the analysis files created.",
"parentId": "msg-task-rag",
"createdAt": 1735526810000,
"updatedAt": 1735526815000,
"agentId": "agent-main",
"model": "gpt-4",
"provider": "openai",
"tools": [
{
"id": "call_list_files",
"type": "builtin",
"apiName": "listFiles",
"arguments": "{\"path\": \"/analysis\"}",
"identifier": "lobe-local-system"
}
],
"metadata": {
"totalInputTokens": 100,
"totalOutputTokens": 50,
"totalTokens": 150,
"cost": 0.001
}
},
{
"id": "msg-tool-list-files",
"role": "tool",
"content": "./ANALYSIS_1.md\n./ANALYSIS_2.md\n./ANALYSIS_3.md",
"parentId": "msg-assistant-after-task",
"tool_call_id": "call_list_files",
"createdAt": 1735526816000,
"updatedAt": 1735526817000,
"pluginState": {
"result": {
"output": "./ANALYSIS_1.md\n./ANALYSIS_2.md\n./ANALYSIS_3.md",
"success": true
}
}
},
{
"id": "msg-assistant-final",
"role": "assistant",
"content": "I found 3 analysis files. Let me summarize the findings for you.",
"parentId": "msg-tool-list-files",
"createdAt": 1735526820000,
"updatedAt": 1735526825000,
"agentId": "agent-main",
"model": "gpt-4",
"provider": "openai",
"metadata": {
"totalInputTokens": 200,
"totalOutputTokens": 80,
"totalTokens": 280,
"cost": 0.002
}
}
]
@@ -219,6 +219,28 @@ describe('parse', () => {
expect(result.flatList[3].id).toBe('msg-assistant-summary');
expect(result.flatList[3].content).toContain('All 10 tasks completed');
});
it('should merge assistant with tools after task into AssistantGroup', () => {
const result = parse(inputs.tasks.withAssistantGroup);
// The critical assertions:
// 1. flatList should have 4 items: user, assistantGroup(+tool), tasks(3 tasks), assistantGroup(with tool chain)
expect(result.flatList).toHaveLength(4);
expect(result.flatList[0].role).toBe('user');
expect(result.flatList[1].role).toBe('assistantGroup');
expect(result.flatList[2].role).toBe('tasks');
expect(result.flatList[3].role).toBe('assistantGroup');
// 2. The last assistantGroup should contain the full chain:
// - msg-assistant-after-task (with tool)
// - msg-assistant-final (without tool)
const lastGroup = result.flatList[3] as any;
expect(lastGroup.children).toHaveLength(2);
expect(lastGroup.children[0].id).toBe('msg-assistant-after-task');
expect(lastGroup.children[0].tools).toBeDefined();
expect(lastGroup.children[0].tools[0].result_msg_id).toBe('msg-tool-list-files');
expect(lastGroup.children[1].id).toBe('msg-assistant-final');
});
});
describe('Performance', () => {
@@ -107,9 +107,18 @@ export class FlatListBuilder {
if (!processedIds.has(nonTaskChildId)) {
const nonTaskChild = this.messageMap.get(nonTaskChildId);
if (nonTaskChild) {
flatList.push(nonTaskChild);
processedIds.add(nonTaskChildId);
this.buildFlatListRecursive(nonTaskChildId, flatList, processedIds, allMessages);
// Check if it's an AssistantGroup (assistant with tools)
if (
nonTaskChild.role === 'assistant' &&
nonTaskChild.tools &&
nonTaskChild.tools.length > 0
) {
this.processAssistantGroup(nonTaskChild, flatList, processedIds, allMessages);
} else {
flatList.push(nonTaskChild);
processedIds.add(nonTaskChildId);
this.buildFlatListRecursive(nonTaskChildId, flatList, processedIds, allMessages);
}
}
}
}
@@ -121,14 +130,28 @@ export class FlatListBuilder {
if (!processedIds.has(taskGrandchildId)) {
const taskGrandchild = this.messageMap.get(taskGrandchildId);
if (taskGrandchild && taskGrandchild.role !== 'task') {
flatList.push(taskGrandchild);
processedIds.add(taskGrandchildId);
this.buildFlatListRecursive(
taskGrandchildId,
flatList,
processedIds,
allMessages,
);
// Check if it's an AssistantGroup (assistant with tools)
if (
taskGrandchild.role === 'assistant' &&
taskGrandchild.tools &&
taskGrandchild.tools.length > 0
) {
this.processAssistantGroup(
taskGrandchild,
flatList,
processedIds,
allMessages,
);
} else {
flatList.push(taskGrandchild);
processedIds.add(taskGrandchildId);
this.buildFlatListRecursive(
taskGrandchildId,
flatList,
processedIds,
allMessages,
);
}
}
}
}
@@ -427,6 +450,60 @@ export class FlatListBuilder {
}
}
/**
* Process an assistant message with tools into an AssistantGroup
* Extracted to avoid code duplication in task children handling
*/
private processAssistantGroup(
message: Message,
flatList: Message[],
processedIds: Set<string>,
allMessages: Message[],
): void {
// Collect the entire assistant group chain
const assistantChain: Message[] = [];
const allToolMessages: Message[] = [];
this.messageCollector.collectAssistantChain(
message,
allMessages,
assistantChain,
allToolMessages,
processedIds,
);
// Create assistantGroup virtual message
const groupMessage = this.createAssistantGroupMessage(
assistantChain[0],
assistantChain,
allToolMessages,
);
flatList.push(groupMessage);
// Mark all as processed
assistantChain.forEach((m) => processedIds.add(m.id));
allToolMessages.forEach((m) => processedIds.add(m.id));
// Continue after the assistant chain
// Priority 1: If last assistant has non-tool children, continue from it
// Priority 2: Otherwise continue from tools (for cases where user replies to tool)
const lastAssistant = assistantChain.at(-1);
const toolIds = new Set(allToolMessages.map((t) => t.id));
const lastAssistantNonToolChildren = lastAssistant
? this.childrenMap.get(lastAssistant.id)?.filter((childId) => !toolIds.has(childId))
: undefined;
if (lastAssistantNonToolChildren && lastAssistantNonToolChildren.length > 0 && lastAssistant) {
// Follow-up messages exist after the last assistant (not tools)
this.buildFlatListRecursive(lastAssistant.id, flatList, processedIds, allMessages);
} else {
// No non-tool children of last assistant, check tools for children
for (const toolMsg of allToolMessages) {
this.buildFlatListRecursive(toolMsg.id, flatList, processedIds, allMessages);
}
}
}
/**
* Check if message has compare mode in metadata
*/
-4
View File
@@ -86,10 +86,6 @@ export interface ChatStreamPayload {
* @title Number of texts to return
*/
n?: number;
/**
* List of enabled plugins
*/
plugins?: string[];
/**
* @title Penalty coefficient in generated text to reduce topic changes
* @default 0
+109 -104
View File
@@ -15,6 +15,35 @@ import { aiModelSelectors } from '@/store/aiInfra';
import { useToolStore } from '@/store/tool';
import { chatService } from './index';
import type { ResolvedAgentConfig } from './mecha';
/**
* Default mock resolvedAgentConfig for tests
*/
const createMockResolvedConfig = (overrides?: {
agentConfig?: Partial<ResolvedAgentConfig['agentConfig']>;
chatConfig?: Partial<ResolvedAgentConfig['chatConfig']>;
plugins?: string[];
isBuiltinAgent?: boolean;
}): ResolvedAgentConfig =>
({
agentConfig: {
model: DEFAULT_AGENT_CONFIG.model,
provider: 'openai',
systemRole: '',
chatConfig: {},
params: {},
tts: {},
...overrides?.agentConfig,
},
chatConfig: {
searchMode: 'off',
autoCreateTopicThreshold: 2,
...overrides?.chatConfig,
},
isBuiltinAgent: overrides?.isBuiltinAgent ?? false,
plugins: overrides?.plugins ?? [],
}) as ResolvedAgentConfig;
// Mocking external dependencies
vi.mock('i18next', () => ({
@@ -109,7 +138,10 @@ describe('ChatService', () => {
],
});
});
await chatService.createAssistantMessage({ messages, plugins: enabledPlugins });
await chatService.createAssistantMessage({
messages,
resolvedAgentConfig: createMockResolvedConfig({ plugins: enabledPlugins }),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -136,21 +168,14 @@ describe('ChatService', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['enableReasoning']);
// Mock agent chat config with reasoning enabled
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
enableReasoning: true,
reasoningBudgetToken: 2048,
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'deepseek-reasoner',
provider: 'deepseek',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'deepseek-reasoner', provider: 'deepseek' },
chatConfig: { enableReasoning: true, reasoningBudgetToken: 2048 },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -172,20 +197,14 @@ describe('ChatService', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['enableReasoning']);
// Mock agent chat config with reasoning disabled
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
enableReasoning: false,
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'deepseek-reasoner',
provider: 'deepseek',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'deepseek-reasoner', provider: 'deepseek' },
chatConfig: { enableReasoning: false },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -207,21 +226,15 @@ describe('ChatService', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['enableReasoning']);
// Mock agent chat config with reasoning enabled but no custom budget
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
enableReasoning: true,
// reasoningBudgetToken is undefined
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'deepseek-reasoner',
provider: 'deepseek',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'deepseek-reasoner', provider: 'deepseek' },
// enableReasoning is true, but reasoningBudgetToken is undefined
chatConfig: { enableReasoning: true },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -243,20 +256,14 @@ describe('ChatService', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['reasoningEffort']);
// Mock agent chat config with reasoning effort set
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
reasoningEffort: 'high',
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'test-model',
provider: 'test-provider',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'test-model', provider: 'test-provider' },
chatConfig: { reasoningEffort: 'high' },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -275,20 +282,14 @@ describe('ChatService', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['thinkingBudget']);
// Mock agent chat config with thinking budget set
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
thinkingBudget: 5000,
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'test-model',
provider: 'test-provider',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'test-model', provider: 'test-provider' },
chatConfig: { thinkingBudget: 5000 },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -329,9 +330,11 @@ describe('ChatService', () => {
const getChatCompletionSpy = vi.spyOn(chatService, 'getChatCompletion');
await chatService.createAssistantMessage({
messages,
plugins: [],
model: 'gpt-4-vision-preview',
provider: 'openai',
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-4-vision-preview', provider: 'openai' },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -378,7 +381,10 @@ describe('ChatService', () => {
] as UIChatMessage[];
const getChatCompletionSpy = vi.spyOn(chatService, 'getChatCompletion');
await chatService.createAssistantMessage({ messages, plugins: [] });
await chatService.createAssistantMessage({
messages,
resolvedAgentConfig: createMockResolvedConfig(),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
{
@@ -435,8 +441,10 @@ describe('ChatService', () => {
await chatService.createAssistantMessage({
messages,
plugins: [],
model: 'gpt-4-vision-preview',
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-4-vision-preview' },
}),
});
// Verify the utility functions were called
@@ -525,8 +533,10 @@ describe('ChatService', () => {
await chatService.createAssistantMessage({
messages,
plugins: [],
model: 'gpt-4-vision-preview',
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-4-vision-preview' },
}),
});
// Verify the utility functions were called
@@ -630,8 +640,10 @@ describe('ChatService', () => {
await chatService.createAssistantMessage({
messages,
plugins: [],
model: 'gpt-4-vision-preview',
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-4-vision-preview' },
}),
});
// Verify isDesktopLocalStaticServerUrl was called for each image
@@ -730,7 +742,10 @@ describe('ChatService', () => {
messages,
model: 'gpt-3.5-turbo-1106',
top_p: 1,
plugins: ['seo'],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-3.5-turbo-1106' },
plugins: ['seo'],
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -832,7 +847,10 @@ describe('ChatService', () => {
messages,
model: 'gpt-3.5-turbo-1106',
top_p: 1,
plugins: ['seo'],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-3.5-turbo-1106' },
plugins: ['seo'],
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -888,7 +906,9 @@ describe('ChatService', () => {
messages,
model: 'gpt-3.5-turbo-1106',
top_p: 1,
plugins: ['ttt'],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'gpt-3.5-turbo-1106' },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -949,7 +969,10 @@ describe('ChatService', () => {
mockToolsEngine as any,
);
await chatService.createAssistantMessage({ messages, plugins: [] });
await chatService.createAssistantMessage({
messages,
resolvedAgentConfig: createMockResolvedConfig(),
});
// Verify tools were passed to getChatCompletion
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -1003,7 +1026,10 @@ describe('ChatService', () => {
mockToolsEngine as any,
);
await chatService.createAssistantMessage({ messages, plugins: [] });
await chatService.createAssistantMessage({
messages,
resolvedAgentConfig: createMockResolvedConfig(),
});
// Verify enabledSearch was set to true
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -1051,7 +1077,10 @@ describe('ChatService', () => {
mockToolsEngine as any,
);
await chatService.createAssistantMessage({ messages, plugins: [] });
await chatService.createAssistantMessage({
messages,
resolvedAgentConfig: createMockResolvedConfig(),
});
// Verify enabledSearch was not set
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -1342,20 +1371,14 @@ describe('ChatService private methods', () => {
'disableContextCaching',
]);
// Mock agent chat config with context caching disabled
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
disableContextCaching: true,
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'test-model',
provider: 'test-provider',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'test-model', provider: 'test-provider' },
chatConfig: { disableContextCaching: true },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -1378,20 +1401,14 @@ describe('ChatService private methods', () => {
'disableContextCaching',
]);
// Mock agent chat config with context caching enabled (default)
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
disableContextCaching: false,
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'test-model',
provider: 'test-provider',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'test-model', provider: 'test-provider' },
chatConfig: { disableContextCaching: false },
}),
});
// enabledContextCaching should not be present in the call
@@ -1407,20 +1424,14 @@ describe('ChatService private methods', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['reasoningEffort']);
// Mock agent chat config with reasoning effort set
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
reasoningEffort: 'high',
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'test-model',
provider: 'test-provider',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'test-model', provider: 'test-provider' },
chatConfig: { reasoningEffort: 'high' },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
@@ -1439,20 +1450,14 @@ describe('ChatService private methods', () => {
vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValue(() => true);
vi.spyOn(aiModelSelectors, 'modelExtendParams').mockReturnValue(() => ['thinkingBudget']);
// Mock agent chat config with thinking budget set
vi.spyOn(chatConfigByIdSelectors, 'getChatConfigById').mockReturnValue(
() =>
({
thinkingBudget: 5000,
searchMode: 'off',
}) as any,
);
await chatService.createAssistantMessage({
messages,
model: 'test-model',
provider: 'test-provider',
plugins: [],
resolvedAgentConfig: createMockResolvedConfig({
agentConfig: { model: 'test-model', provider: 'test-provider' },
chatConfig: { thinkingBudget: 5000 },
}),
});
expect(getChatCompletionSpy).toHaveBeenCalledWith(
+13 -32
View File
@@ -27,7 +27,7 @@ import { ModelProvider } from 'model-bank';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { enableAuth } from '@/envs/auth';
import { getSearchConfig } from '@/helpers/getSearchConfig';
import { createAgentToolsEngine, createToolsEngine } from '@/helpers/toolEngineering';
import { createAgentToolsEngine } from '@/helpers/toolEngineering';
import { getAgentStoreState } from '@/store/agent';
import {
agentByIdSelectors,
@@ -58,10 +58,10 @@ import { createHeaderWithAuth } from '../_auth';
import { API_ENDPOINTS } from '../_url';
import { findDeploymentName, isEnableFetchOnClient, resolveRuntimeProvider } from './helper';
import {
type ResolvedAgentConfig,
contextEngineering,
getTargetAgentId,
initializeWithClientStore,
resolveAgentConfig,
resolveModelExtendParams,
} from './mecha';
import { type FetchOptions } from './types';
@@ -70,6 +70,11 @@ interface GetChatCompletionPayload extends Partial<Omit<ChatStreamPayload, 'mess
agentId?: string;
groupId?: string;
messages: UIChatMessage[];
/**
* Pre-resolved agent config from AgentRuntime layer.
* Required to ensure config consistency and proper isSubTask filtering.
*/
resolvedAgentConfig: ResolvedAgentConfig;
scope?: MessageMapScope;
topicId?: string;
}
@@ -107,12 +112,11 @@ interface CreateAssistantMessageStream extends FetchSSEOptions {
class ChatService {
createAssistantMessage = async (
{
plugins: enabledPlugins,
messages,
agentId,
groupId,
scope,
topicId,
resolvedAgentConfig,
...params
}: GetChatCompletionPayload,
options?: FetchOptions,
@@ -126,24 +130,13 @@ class ChatService {
params,
);
// =================== 1. resolve agent config =================== //
// =================== 1. use pre-resolved agent config =================== //
// Config is resolved in AgentRuntime layer (internal_createAgentState)
// which handles isSubTask filtering and other runtime modifications
const targetAgentId = getTargetAgentId(agentId);
// Resolve agent config with builtin agent runtime config merged
// plugins is already merged (runtime plugins > agent config plugins)
const {
agentConfig,
chatConfig,
plugins: pluginIds,
} = resolveAgentConfig({
agentId: targetAgentId,
groupId, // Pass groupId for supervisor detection
model: payload.model,
plugins: enabledPlugins,
provider: payload.provider,
scope, // Pass scope to preserve page-agent injection
});
const { agentConfig, chatConfig, plugins: pluginIds } = resolvedAgentConfig;
// Get search config with agentId for agent-specific settings
const searchConfig = getSearchConfig(payload.model, payload.provider!, targetAgentId);
@@ -495,26 +488,14 @@ class ChatService {
onLoadingChange?.(true);
try {
// Use simple tools engine without complex search logic
const toolsEngine = createToolsEngine();
const { tools, enabledManifests } = toolsEngine.generateToolsDetailed({
model: params.model!,
provider: params.provider!,
toolIds: params.plugins,
});
const llmMessages = await contextEngineering({
manifests: enabledManifests,
messages: params.messages as any,
model: params.model!,
provider: params.provider!,
tools: params.plugins,
});
// remove plugins
delete params.plugins;
await this.getChatCompletion(
{ ...params, messages: llmMessages, tools },
{ ...params, messages: llmMessages },
{
onErrorHandle: (error) => {
errorHandle(new Error(error.message), error);
@@ -800,4 +800,117 @@ describe('resolveAgentConfig', () => {
expect(result.agentConfig.systemRole).toBe('Supervisor system role');
});
});
describe('sub-task filtering (isSubTask)', () => {
beforeEach(() => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentSlugById').mockReturnValue(() => undefined);
});
it('should filter out lobe-gtd when isSubTask is true for regular agent', () => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentConfigById').mockReturnValue(
() =>
({
...mockAgentConfig,
plugins: ['lobe-gtd', 'plugin-a', 'plugin-b'],
}) as any,
);
const result = resolveAgentConfig({
agentId: 'test-agent',
isSubTask: true,
});
expect(result.plugins).not.toContain('lobe-gtd');
expect(result.plugins).toEqual(['plugin-a', 'plugin-b']);
});
it('should keep lobe-gtd when isSubTask is false', () => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentConfigById').mockReturnValue(
() =>
({
...mockAgentConfig,
plugins: ['lobe-gtd', 'plugin-a', 'plugin-b'],
}) as any,
);
const result = resolveAgentConfig({
agentId: 'test-agent',
isSubTask: false,
});
expect(result.plugins).toContain('lobe-gtd');
expect(result.plugins).toEqual(['lobe-gtd', 'plugin-a', 'plugin-b']);
});
it('should keep lobe-gtd when isSubTask is undefined', () => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentConfigById').mockReturnValue(
() =>
({
...mockAgentConfig,
plugins: ['lobe-gtd', 'plugin-a'],
}) as any,
);
const result = resolveAgentConfig({ agentId: 'test-agent' });
expect(result.plugins).toContain('lobe-gtd');
});
it('should filter lobe-gtd in page scope when isSubTask is true', () => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentConfigById').mockReturnValue(
() =>
({
...mockAgentConfig,
plugins: ['lobe-gtd', 'plugin-a'],
}) as any,
);
vi.spyOn(builtinAgents, 'getAgentRuntimeConfig').mockReturnValue({
systemRole: 'Page agent system role',
});
const result = resolveAgentConfig({
agentId: 'test-agent',
scope: 'page',
isSubTask: true,
});
expect(result.plugins).not.toContain('lobe-gtd');
expect(result.plugins).toContain(PageAgentIdentifier);
});
it('should filter lobe-gtd for builtin agent when isSubTask is true', () => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentSlugById').mockReturnValue(
() => 'some-builtin-slug',
);
vi.spyOn(builtinAgents, 'getAgentRuntimeConfig').mockReturnValue({
plugins: ['lobe-gtd', 'runtime-plugin'],
systemRole: 'Runtime system role',
});
const result = resolveAgentConfig({
agentId: 'builtin-agent',
isSubTask: true,
});
expect(result.plugins).not.toContain('lobe-gtd');
expect(result.plugins).toContain('runtime-plugin');
});
it('should keep lobe-gtd for builtin agent when isSubTask is false', () => {
vi.spyOn(agentSelectors.agentSelectors, 'getAgentSlugById').mockReturnValue(
() => 'some-builtin-slug',
);
vi.spyOn(builtinAgents, 'getAgentRuntimeConfig').mockReturnValue({
plugins: ['lobe-gtd', 'runtime-plugin'],
systemRole: 'Runtime system role',
});
const result = resolveAgentConfig({
agentId: 'builtin-agent',
isSubTask: false,
});
expect(result.plugins).toContain('lobe-gtd');
});
});
});
+15 -5
View File
@@ -61,6 +61,12 @@ export interface AgentConfigResolverContext {
*/
groupId?: string;
/**
* Whether this is a sub-task execution.
* When true, filters out lobe-gtd tools to prevent nested sub-task creation.
*/
isSubTask?: boolean;
/** Current model being used (for template variables) */
model?: string;
/** Plugins enabled for the agent */
@@ -106,9 +112,13 @@ export interface ResolvedAgentConfig {
* For regular agents, this simply returns the config from the store.
*/
export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAgentConfig => {
const { agentId, model, documentContent, plugins, targetAgentConfig } = ctx;
const { agentId, model, documentContent, plugins, targetAgentConfig, isSubTask } = ctx;
log('resolveAgentConfig called with agentId: %s, scope: %s', agentId, ctx.scope);
log('resolveAgentConfig called with agentId: %s, scope: %s, isSubTask: %s', agentId, ctx.scope, isSubTask);
// Helper to filter out lobe-gtd in sub-task context to prevent nested sub-task creation
const applySubTaskFilter = (pluginIds: string[]) =>
isSubTask ? pluginIds.filter((id) => id !== 'lobe-gtd') : pluginIds;
const agentStoreState = getAgentStoreState();
@@ -199,7 +209,7 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
agentConfig: finalAgentConfig,
chatConfig: finalChatConfig,
isBuiltinAgent: false,
plugins: pageAgentPlugins,
plugins: applySubTaskFilter(pageAgentPlugins),
};
}
@@ -208,7 +218,7 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
agentConfig: finalAgentConfig,
chatConfig: finalChatConfig,
isBuiltinAgent: false,
plugins: finalPlugins,
plugins: applySubTaskFilter(finalPlugins),
};
}
@@ -329,7 +339,7 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
agentConfig: finalAgentConfig,
chatConfig: resolvedChatConfig,
isBuiltinAgent: true,
plugins: finalPlugins,
plugins: applySubTaskFilter(finalPlugins),
slug,
};
};
@@ -1,9 +1,21 @@
import type { AgentInstruction, AgentState } from '@lobechat/agent-runtime';
import { DEFAULT_AGENT_CHAT_CONFIG, DEFAULT_AGENT_CONFIG } from '@/const/settings';
import type { ResolvedAgentConfig } from '@/services/chat/mecha';
import { createAgentExecutors } from '@/store/chat/agents/createAgentExecutors';
import type { OperationType } from '@/store/chat/slices/operation/types';
import type { ChatStore } from '@/store/chat/store';
/**
* Create a mock ResolvedAgentConfig for testing
*/
const createMockResolvedAgentConfig = (): ResolvedAgentConfig => ({
agentConfig: { ...DEFAULT_AGENT_CONFIG },
chatConfig: { ...DEFAULT_AGENT_CHAT_CONFIG },
isBuiltinAgent: false,
plugins: [],
});
/**
* Execute an executor with mock context
*
@@ -60,6 +72,7 @@ export const executeWithMockContext = async ({
// Create executors with mock context
const executors = createAgentExecutors({
agentConfig: createMockResolvedAgentConfig(),
get: () => mockStore,
messageKey: context.messageKey,
operationId: context.operationId,
+13 -1
View File
@@ -22,6 +22,8 @@ import type { ChatToolPayload, ConversationContext, CreateMessageParams } from '
import debug from 'debug';
import pMap from 'p-map';
import type { ResolvedAgentConfig } from '@/services/chat/mecha';
import { LOADING_FLAT } from '@/const/message';
import { aiAgentService } from '@/services/aiAgent';
import { agentByIdSelectors } from '@/store/agent/selectors';
@@ -49,6 +51,8 @@ const TOOL_PRICING: Record<string, number> = {
* @param context.skipCreateFirstMessage - Skip first message creation
*/
export const createAgentExecutors = (context: {
/** Pre-resolved agent config with isSubTask filtering applied */
agentConfig: ResolvedAgentConfig;
get: () => ChatStore;
messageKey: string;
operationId: string;
@@ -169,6 +173,7 @@ export const createAgentExecutors = (context: {
model: llmPayload.model,
provider: llmPayload.provider,
operationId: context.operationId,
agentConfig: context.agentConfig, // Pass pre-resolved config
// Pass runtime context for page editor injection
initialContext: runtimeContext?.initialContext,
stepContext: runtimeContext?.stepContext,
@@ -1735,7 +1740,12 @@ export const createAgentExecutors = (context: {
const { threadId, userMessageId, threadMessages, messages } = threadResult;
// 3. Build sub-task ConversationContext (uses threadId for isolation)
const subContext: ConversationContext = { agentId, topicId, threadId, scope: 'thread' };
const subContext: ConversationContext = {
agentId,
topicId,
threadId,
scope: 'thread',
};
// 4. Create a child operation for task execution (now with threadId)
const { operationId: taskOperationId } = context.get().startOperation({
@@ -1784,6 +1794,7 @@ export const createAgentExecutors = (context: {
parentMessageType: 'user',
operationId: taskOperationId,
parentOperationId: state.operationId,
isSubTask: true, // Disable lobe-gtd tools to prevent nested sub-tasks
});
log('[%s][exec_client_task] Client-side AgentRuntime execution completed', taskLogId);
@@ -2107,6 +2118,7 @@ export const createAgentExecutors = (context: {
parentMessageType: 'user',
operationId: taskOperationId,
parentOperationId: state.operationId,
isSubTask: true, // Disable lobe-gtd tools to prevent nested sub-tasks
});
log('[%s] Client-side AgentRuntime execution completed', taskLogId);
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useChatStore } from '../../../../store';
import { messageMapKey } from '../../../../utils/messageMapKey';
import { TEST_IDS, createMockMessage } from './fixtures';
import { TEST_IDS, createMockMessage, createMockResolvedAgentConfig } from './fixtures';
import { resetTestEnvironment } from './helpers';
// Keep zustand mock as it's needed globally
@@ -425,6 +425,7 @@ describe('ConversationControl actions', () => {
.mockReturnValue({
state: {} as any,
context: { phase: 'init' } as any,
agentConfig: createMockResolvedAgentConfig(),
});
const internal_execAgentRuntimeSpy = vi
.spyOn(result.current, 'internal_execAgentRuntime')
@@ -497,6 +498,7 @@ describe('ConversationControl actions', () => {
.mockReturnValue({
state: {} as any,
context: { phase: 'init' } as any,
agentConfig: createMockResolvedAgentConfig(),
});
const internal_execAgentRuntimeSpy = vi
.spyOn(result.current, 'internal_execAgentRuntime')
@@ -596,6 +598,7 @@ describe('ConversationControl actions', () => {
.mockReturnValue({
state: {} as any,
context: { phase: 'init' } as any,
agentConfig: createMockResolvedAgentConfig(),
});
const internal_execAgentRuntimeSpy = vi
.spyOn(result.current, 'internal_execAgentRuntime')
@@ -669,6 +672,7 @@ describe('ConversationControl actions', () => {
.mockReturnValue({
state: {} as any,
context: { phase: 'init' } as any,
agentConfig: createMockResolvedAgentConfig(),
});
const internal_execAgentRuntimeSpy = vi
.spyOn(result.current, 'internal_execAgentRuntime')
@@ -1,6 +1,7 @@
import { type UIChatMessage } from '@lobechat/types';
import { DEFAULT_AGENT_CHAT_CONFIG, DEFAULT_AGENT_CONFIG } from '@/const/settings';
import type { ResolvedAgentConfig } from '@/services/chat/mecha';
// Test Constants
export const TEST_IDS = {
@@ -63,3 +64,16 @@ export const createMockStoreState = (overrides = {}) => ({
toolCallingStreamIds: {},
...overrides,
});
/**
* Create a mock ResolvedAgentConfig for testing
*/
export const createMockResolvedAgentConfig = (
overrides: Partial<ResolvedAgentConfig> = {},
): ResolvedAgentConfig => ({
agentConfig: createMockAgentConfig(),
chatConfig: createMockChatConfig(),
isBuiltinAgent: false,
plugins: [],
...overrides,
});
@@ -13,6 +13,7 @@ import {
createMockAgentConfig,
createMockChatConfig,
createMockMessage,
createMockResolvedAgentConfig,
} from './fixtures';
import { resetTestEnvironment, setupMockSelectors, spyOnMessageService } from './helpers';
@@ -57,6 +58,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
agentConfig: createMockResolvedAgentConfig(),
});
expect(response.isFunctionCall).toEqual(false);
expect(response.content).toEqual(TEST_CONTENT.AI_RESPONSE);
@@ -83,6 +85,7 @@ describe('StreamingExecutor actions', () => {
provider: 'openai',
messages,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -127,6 +130,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
agentConfig: createMockResolvedAgentConfig(),
});
expect(response.isFunctionCall).toEqual(true);
});
@@ -165,6 +169,7 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -213,6 +218,7 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -251,6 +257,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -300,6 +307,7 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -355,6 +363,7 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -394,6 +403,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
agentConfig: createMockResolvedAgentConfig(),
});
expect(response.isFunctionCall).toEqual(true);
});
@@ -419,6 +429,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -435,7 +446,7 @@ describe('StreamingExecutor actions', () => {
});
describe('effectiveAgentId for group orchestration', () => {
it('should pass effectiveAgentId (subAgentId) to chatService when subAgentId is set in operation context', async () => {
it('should pass pre-resolved config for sub-agent when subAgentId is set in operation context', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
const supervisorAgentId = 'supervisor-agent-id';
@@ -453,6 +464,9 @@ describe('StreamingExecutor actions', () => {
label: 'Test Group Orchestration',
});
// Pre-resolved config for the sub-agent (in real usage, resolved by internal_createAgentState)
const subAgentConfig = createMockResolvedAgentConfig();
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
@@ -466,14 +480,18 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: subAgentConfig,
});
});
// Verify chatService was called with subAgentId (effectiveAgentId), not supervisorAgentId
// With the new architecture:
// - agentId param is for context/tracing (supervisor ID)
// - resolvedAgentConfig contains the sub-agent's config (passed in by caller)
expect(streamSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
agentId: subAgentId, // Should be subAgentId, not supervisorAgentId
agentId: supervisorAgentId, // For context/tracing purposes
resolvedAgentConfig: subAgentConfig, // Pre-resolved sub-agent config
}),
}),
);
@@ -511,6 +529,7 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -526,7 +545,7 @@ describe('StreamingExecutor actions', () => {
streamSpy.mockRestore();
});
it('should use subAgentId for agent config resolution when present', async () => {
it('should pass resolvedAgentConfig through chatService when subAgentId is present', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
const supervisorAgentId = 'supervisor-agent-id';
@@ -547,6 +566,9 @@ describe('StreamingExecutor actions', () => {
label: 'Test Speak Executor',
});
// Create a mock resolved config that represents the speaking agent's config
const speakingAgentConfig = createMockResolvedAgentConfig();
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
@@ -560,15 +582,23 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
// Pass pre-resolved config for the speaking agent
// In real usage, this is resolved in internal_createAgentState using subAgentId
agentConfig: speakingAgentConfig,
});
});
// The key assertion: chatService should receive subAgentId for agent config resolution
// This ensures the speaking agent's system role and tools are used, not the supervisor's
// With the new architecture, config is pre-resolved and passed via resolvedAgentConfig.
// The agentId param is for context/tracing only.
// The speaking agent's config is ensured by the caller (internal_createAgentState)
// resolving config with subAgentId and passing it as agentConfig param.
expect(streamSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
agentId: subAgentId,
// agentId is supervisor for context purposes
agentId: supervisorAgentId,
// resolvedAgentConfig contains the speaking agent's config
resolvedAgentConfig: speakingAgentConfig,
}),
}),
);
@@ -902,6 +932,7 @@ describe('StreamingExecutor actions', () => {
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -943,6 +974,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
agentConfig: createMockResolvedAgentConfig(),
});
});
@@ -1040,6 +1072,7 @@ describe('StreamingExecutor actions', () => {
stepCount: 0,
},
},
agentConfig: createMockResolvedAgentConfig(),
});
// Execute internal_execAgentRuntime with the pre-created operationId
@@ -1135,6 +1168,7 @@ describe('StreamingExecutor actions', () => {
stepCount: 0,
},
},
agentConfig: createMockResolvedAgentConfig(),
});
// Suppress console.error for this test
@@ -1235,6 +1269,7 @@ describe('StreamingExecutor actions', () => {
stepCount: 0,
},
},
agentConfig: createMockResolvedAgentConfig(),
});
// Should not throw
@@ -1489,6 +1524,7 @@ describe('StreamingExecutor actions', () => {
stepCount: 1,
},
},
agentConfig: createMockResolvedAgentConfig(),
});
await act(async () => {
@@ -1583,6 +1619,7 @@ describe('StreamingExecutor actions', () => {
stepCount: 1,
},
},
agentConfig: createMockResolvedAgentConfig(),
});
await act(async () => {
@@ -1602,4 +1639,91 @@ describe('StreamingExecutor actions', () => {
expect(result.current.operations[operationId!].status).toBe('failed');
});
});
describe('isSubTask filtering', () => {
it('should filter out lobe-gtd tools when isSubTask is true', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
// Mock resolveAgentConfig to return plugins including lobe-gtd
const resolveAgentConfigSpy = vi
.spyOn(agentConfigResolver, 'resolveAgentConfig')
.mockReturnValue({
agentConfig: createMockAgentConfig(),
chatConfig: createMockChatConfig(),
isBuiltinAgent: false,
plugins: ['lobe-gtd', 'lobe-local-system', 'other-plugin'],
});
// Create operation
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'execClientTask',
context: {
agentId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
},
});
operationId = res.operationId;
});
// Call internal_createAgentState with isSubTask: true
act(() => {
result.current.internal_createAgentState({
messages,
parentMessageId: TEST_IDS.USER_MESSAGE_ID,
operationId,
isSubTask: true,
});
});
// Verify that resolveAgentConfig was called
expect(resolveAgentConfigSpy).toHaveBeenCalled();
resolveAgentConfigSpy.mockRestore();
});
it('should NOT filter out lobe-gtd tools when isSubTask is false or undefined', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
// Mock resolveAgentConfig to return plugins including lobe-gtd
const resolveAgentConfigSpy = vi
.spyOn(agentConfigResolver, 'resolveAgentConfig')
.mockReturnValue({
agentConfig: createMockAgentConfig(),
chatConfig: createMockChatConfig(),
isBuiltinAgent: false,
plugins: ['lobe-gtd', 'lobe-local-system', 'other-plugin'],
});
// Create operation without isSubTask (normal conversation)
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'execAgentRuntime',
context: {
agentId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
},
});
operationId = res.operationId;
});
// Call internal_createAgentState without isSubTask
act(() => {
result.current.internal_createAgentState({
messages,
parentMessageId: TEST_IDS.USER_MESSAGE_ID,
operationId,
});
});
// Verify that resolveAgentConfig was called
expect(resolveAgentConfigSpy).toHaveBeenCalled();
resolveAgentConfigSpy.mockRestore();
});
});
});
@@ -28,7 +28,7 @@ import { type StateCreator } from 'zustand/vanilla';
import { createAgentToolsEngine } from '@/helpers/toolEngineering';
import { chatService } from '@/services/chat';
import { resolveAgentConfig } from '@/services/chat/mecha';
import { type ResolvedAgentConfig, resolveAgentConfig } from '@/services/chat/mecha';
import { messageService } from '@/services/message';
import { createAgentExecutors } from '@/store/chat/agents/createAgentExecutors';
import { type ChatStore } from '@/store/chat/store';
@@ -72,9 +72,15 @@ export interface StreamingExecutorAction {
* Used to get Agent config (model, provider, plugins) instead of agentId
*/
subAgentId?: string;
/**
* Whether this is a sub-task execution (disables lobe-gtd tools to prevent nested sub-tasks)
*/
isSubTask?: boolean;
}) => {
state: AgentState;
context: AgentRuntimeContext;
/** Resolved agent config with isSubTask filtering applied */
agentConfig: ResolvedAgentConfig;
};
/**
* Retrieves an AI-generated chat message from the backend service with streaming
@@ -85,7 +91,8 @@ export interface StreamingExecutorAction {
model: string;
provider: string;
operationId?: string;
agentConfig?: any;
/** Pre-resolved agent config (from internal_createAgentState) with isSubTask filtering applied */
agentConfig: ResolvedAgentConfig;
traceId?: string;
/** Initial context for page editor (captured at operation start) */
initialContext?: RuntimeInitialContext;
@@ -132,6 +139,10 @@ export interface StreamingExecutorAction {
*/
parentOperationId?: string;
skipCreateFirstMessage?: boolean;
/**
* Whether this is a sub-task execution (disables lobe-gtd tools to prevent nested sub-tasks)
*/
isSubTask?: boolean;
}) => Promise<{ cost?: Cost; usage?: Usage } | void>;
}
@@ -151,6 +162,7 @@ export const streamingExecutor: StateCreator<
initialContext,
operationId,
subAgentId: paramSubAgentId,
isSubTask,
}) => {
// Use provided agentId/topicId or fallback to global state
const { activeAgentId, activeTopicId } = get();
@@ -169,11 +181,16 @@ export const streamingExecutor: StateCreator<
// Resolve agent config with builtin agent runtime config merged
// This ensures runtime plugins (e.g., 'lobe-agent-builder' for Agent Builder) are included
const { agentConfig: agentConfigData, plugins: pluginIds } = resolveAgentConfig({
// isSubTask is passed to filter out lobe-gtd tools to prevent nested sub-task creation
const agentConfig = resolveAgentConfig({
agentId: effectiveAgentId || '',
groupId, // Pass groupId for supervisor detection
isSubTask, // Filter out lobe-gtd in sub-task context
scope, // Pass scope from operation context
});
const { agentConfig: agentConfigData, plugins: pluginIds } = agentConfig;
log('[internal_createAgentState] resolved plugins=%o, isSubTask=%s', pluginIds, isSubTask);
// Get tools manifest map
const toolsEngine = createAgentToolsEngine({
@@ -260,7 +277,7 @@ export const streamingExecutor: StateCreator<
initialContext: runtimeInitialContext,
};
return { state, context };
return { state, context, agentConfig };
},
internal_fetchAIChatMessage: async ({
@@ -333,23 +350,10 @@ export const streamingExecutor: StateCreator<
// Create base context for child operations and message queries
const fetchContext = { agentId, topicId, threadId, groupId, scope };
// For group orchestration scenarios:
// - subAgentId is used for agent config retrieval (model, provider, plugins)
// - agentId is used for session ID (message storage location)
const effectiveAgentId = subAgentId || agentId;
// Resolve agent config with params adjusted based on chatConfig
// If agentConfig is passed in, use it directly (it's already resolved)
// Otherwise, resolve from mecha layer which handles:
// - Builtin agent runtime config merging
// - max_tokens/reasoning_effort based on chatConfig settings
const resolved = resolveAgentConfig({
agentId: effectiveAgentId,
groupId, // Pass groupId for supervisor detection
scope, // scope is already available from line 329
});
const finalAgentConfig = agentConfig || resolved.agentConfig;
const chatConfig = resolved.chatConfig;
// Use pre-resolved agent config (from internal_createAgentState)
// This ensures isSubTask filtering and other runtime modifications are preserved
const { agentConfig: agentConfigData, chatConfig, plugins: pluginIds } = agentConfig;
log('[internal_fetchAIChatMessage] using pre-resolved config, plugins=%o', pluginIds);
let finalUsage: ModelUsage | undefined;
let finalToolCalls: MessageToolCall[] | undefined;
@@ -437,18 +441,18 @@ export const streamingExecutor: StateCreator<
await chatService.createAssistantMessageStream({
abortController,
params: {
// Use effectiveAgentId for agent config resolution (system role, tools, etc.)
// In group orchestration: subAgentId for the actual speaking agent
// In normal chat: agentId for the main agent
agentId: effectiveAgentId || undefined,
// agentId is used for context, not for config resolution (config is pre-resolved)
agentId: agentId || undefined,
groupId,
messages,
model,
provider,
// Pass pre-resolved config to avoid duplicate resolveAgentConfig calls
// This ensures isSubTask filtering and other runtime modifications are preserved
resolvedAgentConfig: agentConfig,
scope, // Pass scope to chat service for page-agent injection
topicId, // Pass topicId for GTD context injection
...finalAgentConfig.params,
plugins: finalAgentConfig.plugins,
topicId: topicId ?? undefined, // Pass topicId for GTD context injection
...agentConfigData.params,
},
historySummary: historySummary?.content,
// Pass page editor context from agent runtime
@@ -544,7 +548,13 @@ export const streamingExecutor: StateCreator<
},
internal_execAgentRuntime: async (params) => {
const { messages: originalMessages, parentMessageId, parentMessageType, context } = params;
const {
messages: originalMessages,
parentMessageId,
parentMessageType,
context,
isSubTask,
} = params;
// Extract values from context
const { agentId, topicId, threadId, subAgentId, groupId } = context;
@@ -593,30 +603,32 @@ export const streamingExecutor: StateCreator<
// Create a new array to avoid modifying the original messages
let messages = [...originalMessages];
// Use effectiveAgentId to get agent config (subAgentId in group orchestration, agentId otherwise)
// resolveAgentConfig handles:
// - Builtin agent runtime config merging
// - max_tokens/reasoning_effort based on chatConfig settings
const { agentConfig: agentConfigData } = resolveAgentConfig({
agentId: effectiveAgentId || '',
groupId, // Pass groupId for supervisor detection
scope: context.scope, // Pass scope from context parameter
// ===========================================
// Step 1: Create Agent State (resolves config once)
// ===========================================
// agentConfig contains isSubTask filtering and is passed to callLLM executor
const {
state: initialAgentState,
context: initialAgentContext,
agentConfig,
} = get().internal_createAgentState({
messages,
parentMessageId: params.parentMessageId,
agentId,
topicId,
threadId: threadId ?? undefined,
initialState: params.initialState,
initialContext: params.initialContext,
operationId,
subAgentId, // Pass subAgentId for agent config retrieval
isSubTask, // Pass isSubTask to filter out lobe-gtd tools in sub-task context
});
// Use agent config from agentId
// Use model/provider from resolved agentConfig
const { agentConfig: agentConfigData } = agentConfig;
const model = agentConfigData.model;
const provider = agentConfigData.provider;
// ===========================================
// Step 1: Knowledge Base Tool Integration
// ===========================================
// RAG retrieval is now handled by the Knowledge Base Tool
// The AI will decide when to call searchKnowledgeBase and readKnowledge tools
// based on the conversation context and available knowledge bases
// TODO: Implement selected files full-text injection if needed
// User-selected files should be handled differently from knowledge base files
// ===========================================
// Step 2: Create and Execute Agent Runtime
// ===========================================
@@ -633,6 +645,7 @@ export const streamingExecutor: StateCreator<
const runtime = new AgentRuntime(agent, {
executors: createAgentExecutors({
agentConfig, // Pass pre-resolved config to callLLM executor
get,
messageKey,
operationId,
@@ -650,20 +663,6 @@ export const streamingExecutor: StateCreator<
operationId,
});
// Create agent state and context with user intervention config
const { state: initialAgentState, context: initialAgentContext } =
get().internal_createAgentState({
messages,
parentMessageId: params.parentMessageId,
agentId,
topicId,
threadId: threadId ?? undefined,
initialState: params.initialState,
initialContext: params.initialContext,
operationId,
subAgentId, // Pass subAgentId for agent config retrieval
});
let state = initialAgentState;
let nextContext = initialAgentContext;
@@ -1060,6 +1060,77 @@ describe('ChatPluginAction', () => {
expect(transformed[0].apiName).toBe(longApiName);
});
it('should repair malformed JSON arguments with escaped string issue', () => {
// This is the malformed data from haiku-4.5 model
// The entire JSON got stuffed into the "description" field with escaped quotes
const malformedArguments = JSON.stringify({
description:
'Synthesize all 10 batch analyses into 10 most important themes for product builders", "instruction": "You have access to 10 batch analysis files", "runInClient": true, "timeout": 120000}',
});
const toolCalls: MessageToolCall[] = [
{
id: 'tool1',
function: {
name: ['lobe-gtd', 'execTask', 'default'].join(PLUGIN_SCHEMA_SEPARATOR),
arguments: malformedArguments,
},
type: 'function',
},
];
// Setup builtin tool manifest with schema that has required fields
act(() => {
useToolStore.setState({
builtinTools: [
{
type: 'builtin',
identifier: 'lobe-gtd',
manifest: {
identifier: 'lobe-gtd',
api: [
{
name: 'execTask',
description: 'Execute async task',
parameters: {
type: 'object',
required: ['description', 'instruction'],
properties: {
description: { type: 'string' },
instruction: { type: 'string' },
runInClient: { type: 'boolean' },
timeout: { type: 'number' },
},
},
},
],
type: 'builtin',
} as any,
},
],
});
});
const { result } = renderHook(() => useChatStore());
const transformed = result.current.internal_transformToolCalls(toolCalls);
// Parse the transformed arguments
const repairedArgs = JSON.parse(transformed[0].arguments);
// Verify all fields are correctly extracted
expect(repairedArgs).toHaveProperty('description');
expect(repairedArgs).toHaveProperty('instruction');
expect(repairedArgs).toHaveProperty('runInClient', true);
expect(repairedArgs).toHaveProperty('timeout', 120000);
// Verify description is the correct short value, not the entire malformed string
expect(repairedArgs.description).toBe(
'Synthesize all 10 batch analyses into 10 most important themes for product builders',
);
expect(repairedArgs.instruction).toBe('You have access to 10 batch analysis files');
});
});
describe('internal_updatePluginError', () => {
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import { ToolNameResolver } from '@lobechat/context-engine';
import { ToolArgumentsRepairer, ToolNameResolver } from '@lobechat/context-engine';
import { type ChatToolPayload, type MessageToolCall } from '@lobechat/types';
import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { type StateCreator } from 'zustand/vanilla';
@@ -78,9 +78,18 @@ export const pluginInternals: StateCreator<
// Resolve tool calls and add source field
const resolved = toolNameResolver.resolve(toolCalls, manifests);
return resolved.map((payload) => ({
...payload,
source: sourceMap[payload.identifier],
}));
return resolved.map((payload) => {
// Parse and repair arguments if needed
const manifest = manifests[payload.identifier];
const repairer = new ToolArgumentsRepairer(manifest);
const repairedArgs = repairer.parse(payload.apiName, payload.arguments);
return {
...payload,
arguments: JSON.stringify(repairedArgs),
source: sourceMap[payload.identifier],
};
});
},
});