mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
🐛 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:
@@ -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[],
|
||||
};
|
||||
|
||||
+156
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user