feat(agent mode): add tool call goal titles
This commit is contained in:
@@ -191,7 +191,10 @@ export default class ToolUIHandler {
|
||||
|
||||
const title = document.createElement('span')
|
||||
title.className = 'tool-title'
|
||||
title.textContent = data.stepLabel || this.humanizeFunctionName(answerKey)
|
||||
title.textContent =
|
||||
data.toolCallTitle ||
|
||||
data.stepLabel ||
|
||||
this.humanizeFunctionName(answerKey)
|
||||
|
||||
const subtitle = document.createElement('span')
|
||||
subtitle.className = 'tool-subtitle'
|
||||
@@ -422,6 +425,7 @@ export default class ToolUIHandler {
|
||||
*/
|
||||
updateActivityCard(toolGroupContainer, data, answerKey) {
|
||||
const title =
|
||||
data.toolCallTitle ||
|
||||
data.stepLabel ||
|
||||
this.humanizeFunctionName(data.functionName || answerKey)
|
||||
toolGroupContainer.title.textContent = title
|
||||
|
||||
@@ -406,14 +406,15 @@ export class ReActLLMDuty extends LLMDuty {
|
||||
options
|
||||
)
|
||||
},
|
||||
executeFunction: async (callable, toolInput) => {
|
||||
executeFunction: async (callable, toolInput, toolCallTitle) => {
|
||||
const toolResult = await runToolExecution(
|
||||
callable.toolkitId,
|
||||
callable.toolId,
|
||||
callable.functionName,
|
||||
toolInput,
|
||||
undefined,
|
||||
callable.qualifiedName
|
||||
callable.qualifiedName,
|
||||
toolCallTitle
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
AGENT_LIMIT_RECOVERY_OBSERVATION_MAX_CHARS,
|
||||
AGENT_LIMIT_RECOVERY_REQUEST_MAX_CHARS,
|
||||
AGENT_MAX_PARALLEL_TOOL_CALLS,
|
||||
AGENT_MAX_ITERATIONS
|
||||
AGENT_MAX_ITERATIONS,
|
||||
AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME,
|
||||
AGENT_TOOL_CALL_TITLE_MAX_CHARS
|
||||
} from './constants'
|
||||
import { validateToolInput } from './utils'
|
||||
|
||||
@@ -49,6 +51,7 @@ export const AGENT_SYSTEM_PROMPT = `You are an autonomous agent with tools.
|
||||
|
||||
<tool_policy>
|
||||
- Use only the provided tools.
|
||||
- For every executable toolkit call, set ${AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME} to a very short, action-specific title that explains the immediate goal and includes the key target when useful.
|
||||
- Load the most specific relevant toolkit before acting. Prefer a dedicated toolkit over a general operating-system toolkit when both could perform the task.
|
||||
- When the owner provides a source to understand, prefer direct-source tools over secondary search. Use search as fallback when the source cannot be accessed or does not contain the needed evidence.
|
||||
- Use the exact observed values from earlier tool results when chaining calls.
|
||||
@@ -155,7 +158,8 @@ export interface AgentLoopParams {
|
||||
) => Promise<AgentModelResult | null>
|
||||
executeFunction: (
|
||||
callable: AgentCallableFunction,
|
||||
toolInput: string
|
||||
toolInput: string,
|
||||
toolCallTitle?: string
|
||||
) => Promise<AgentFunctionExecutionResult>
|
||||
loadAgentSkill: (skillId: string) => Promise<AgentSkillContext | null>
|
||||
loadToolkitContext?: (toolkitId: string) => string
|
||||
@@ -301,7 +305,7 @@ function loadToolkitFunctions(
|
||||
function: {
|
||||
name: toolName,
|
||||
description: `${qualifiedName}: ${functionConfig.description}`,
|
||||
parameters: functionConfig.parameters
|
||||
parameters: addToolCallTitleParameter(functionConfig.parameters)
|
||||
}
|
||||
})
|
||||
loadedFunctionCount += 1
|
||||
@@ -315,6 +319,41 @@ function loadToolkitFunctions(
|
||||
return loadedFunctionCount
|
||||
}
|
||||
|
||||
/** Adds Leon-owned display metadata without changing the tool's input schema. */
|
||||
function addToolCallTitleParameter(
|
||||
parameters: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const properties = parameters['properties']
|
||||
const required = parameters['required']
|
||||
const existingProperties =
|
||||
properties && typeof properties === 'object' && !Array.isArray(properties)
|
||||
? properties as Record<string, unknown>
|
||||
: {}
|
||||
const existingRequired = Array.isArray(required)
|
||||
? required.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
|
||||
return {
|
||||
...parameters,
|
||||
properties: {
|
||||
...existingProperties,
|
||||
[AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME]: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: AGENT_TOOL_CALL_TITLE_MAX_CHARS,
|
||||
description:
|
||||
'Very short user-facing title describing the immediate goal of this tool call, including its key target when useful.'
|
||||
}
|
||||
},
|
||||
required: [
|
||||
...existingRequired.filter(
|
||||
(name) => name !== AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME
|
||||
),
|
||||
AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts persisted owner/Leon messages into the same transcript that will
|
||||
* receive agent tool calls. The current owner request is omitted when it was
|
||||
@@ -1050,8 +1089,9 @@ async function executeAgentToolCall(
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallInput = extractToolCallInput(toolCall.function.arguments)
|
||||
const validation = validateToolInput(
|
||||
toolCall.function.arguments,
|
||||
toolCallInput.toolInput,
|
||||
callable.functionConfig.parameters
|
||||
)
|
||||
if (!validation.isValid) {
|
||||
@@ -1062,7 +1102,7 @@ async function executeAgentToolCall(
|
||||
}
|
||||
|
||||
const validatedInput =
|
||||
validation.repairedToolInput ?? toolCall.function.arguments
|
||||
validation.repairedToolInput ?? toolCallInput.toolInput
|
||||
const duplicate =
|
||||
callable.functionConfig.deduplicate_calls === false
|
||||
? null
|
||||
@@ -1082,8 +1122,17 @@ async function executeAgentToolCall(
|
||||
let execution: ExecutionRecord
|
||||
let handoffSignal: FinalResponseSignal | undefined
|
||||
try {
|
||||
const result = await params.executeFunction(callable, validatedInput)
|
||||
execution = result.execution
|
||||
const result = await params.executeFunction(
|
||||
callable,
|
||||
validatedInput,
|
||||
toolCallInput.title
|
||||
)
|
||||
execution = {
|
||||
...result.execution,
|
||||
...(toolCallInput.title
|
||||
? { toolCallTitle: toolCallInput.title }
|
||||
: {})
|
||||
}
|
||||
handoffSignal = result.handoffSignal
|
||||
} catch (error) {
|
||||
// Tool failures stay inside the protocol so the model can recover using
|
||||
@@ -1092,6 +1141,9 @@ async function executeAgentToolCall(
|
||||
function: callable.qualifiedName,
|
||||
status: 'error',
|
||||
observation: `Tool execution failed: ${String(error)}`,
|
||||
...(toolCallInput.title
|
||||
? { toolCallTitle: toolCallInput.title }
|
||||
: {}),
|
||||
stepLabel: callable.qualifiedName,
|
||||
requestedToolInput: validatedInput
|
||||
}
|
||||
@@ -1108,6 +1160,44 @@ async function executeAgentToolCall(
|
||||
}
|
||||
}
|
||||
|
||||
/** Separates Leon-owned display metadata from arguments sent to a tool. */
|
||||
function extractToolCallInput(input: string): {
|
||||
toolInput: string
|
||||
title?: string
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(input) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { toolInput: input }
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>
|
||||
if (!(AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME in record)) {
|
||||
return { toolInput: input }
|
||||
}
|
||||
|
||||
const titleValue = record[AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME]
|
||||
const toolArguments = { ...record }
|
||||
delete toolArguments[AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME]
|
||||
|
||||
if (typeof titleValue !== 'string' || !titleValue.trim()) {
|
||||
return { toolInput: JSON.stringify(toolArguments) }
|
||||
}
|
||||
|
||||
const title = titleValue.trim()
|
||||
const boundedTitle = title.length <= AGENT_TOOL_CALL_TITLE_MAX_CHARS
|
||||
? title
|
||||
: `${title.slice(0, AGENT_TOOL_CALL_TITLE_MAX_CHARS - 3).trimEnd()}...`
|
||||
|
||||
return {
|
||||
toolInput: JSON.stringify(toolArguments),
|
||||
title: boundedTitle
|
||||
}
|
||||
} catch {
|
||||
return { toolInput: input }
|
||||
}
|
||||
}
|
||||
|
||||
function parseStringArgument(input: string, key: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(input) as Record<string, unknown>
|
||||
|
||||
@@ -20,6 +20,8 @@ Rules:
|
||||
|
||||
export const AGENT_MAX_ITERATIONS = 32
|
||||
export const AGENT_MAX_PARALLEL_TOOL_CALLS = 8
|
||||
export const AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME = '_leonToolCallTitle'
|
||||
export const AGENT_TOOL_CALL_TITLE_MAX_CHARS = 72
|
||||
export const AGENT_CONVERGENCE_RESERVE_ITERATIONS = 8
|
||||
export const AGENT_TEMPERATURE = 0.2
|
||||
export const AGENT_INFERENCE_TIMEOUT_MS = 120_000
|
||||
|
||||
@@ -118,6 +118,7 @@ function emitToolExecutionInputToWebApp(params: {
|
||||
functionName: string
|
||||
toolInput: string
|
||||
toolGroupId: string
|
||||
toolCallTitle?: string
|
||||
stepLabel?: string
|
||||
}): void {
|
||||
const displayContext = getToolDisplayContext(
|
||||
@@ -143,6 +144,9 @@ function emitToolExecutionInputToWebApp(params: {
|
||||
toolGroupId: params.toolGroupId,
|
||||
functionName: params.functionName,
|
||||
toolInput: params.toolInput,
|
||||
...(params.toolCallTitle
|
||||
? { toolCallTitle: params.toolCallTitle }
|
||||
: {}),
|
||||
...(params.stepLabel ? { stepLabel: params.stepLabel } : {})
|
||||
})
|
||||
}
|
||||
@@ -153,6 +157,7 @@ function emitToolPreparationProgressToWebApp(params: {
|
||||
functionName: string
|
||||
toolGroupId: string
|
||||
message: string
|
||||
toolCallTitle?: string
|
||||
stepLabel?: string
|
||||
}): void {
|
||||
const message = params.message.trim()
|
||||
@@ -174,6 +179,9 @@ function emitToolPreparationProgressToWebApp(params: {
|
||||
functionName: params.functionName,
|
||||
status: 'running',
|
||||
message,
|
||||
...(params.toolCallTitle
|
||||
? { toolCallTitle: params.toolCallTitle }
|
||||
: {}),
|
||||
...(params.stepLabel ? { stepLabel: params.stepLabel } : {})
|
||||
})
|
||||
}
|
||||
@@ -184,6 +192,7 @@ function emitToolExecutionOutputDeltaToWebApp(params: {
|
||||
functionName: string
|
||||
toolGroupId: string
|
||||
output: string
|
||||
toolCallTitle?: string
|
||||
stepLabel?: string
|
||||
}): void {
|
||||
const output = params.output
|
||||
@@ -205,6 +214,9 @@ function emitToolExecutionOutputDeltaToWebApp(params: {
|
||||
functionName: params.functionName,
|
||||
status: 'running',
|
||||
outputDelta: output,
|
||||
...(params.toolCallTitle
|
||||
? { toolCallTitle: params.toolCallTitle }
|
||||
: {}),
|
||||
...(params.stepLabel ? { stepLabel: params.stepLabel } : {})
|
||||
})
|
||||
}
|
||||
@@ -236,6 +248,7 @@ function emitToolExecutionOutputToWebApp(params: {
|
||||
output: Record<string, unknown>
|
||||
status: string
|
||||
message: string
|
||||
toolCallTitle?: string
|
||||
stepLabel?: string
|
||||
}): void {
|
||||
const outputPayload = {
|
||||
@@ -263,6 +276,9 @@ function emitToolExecutionOutputToWebApp(params: {
|
||||
status: params.status,
|
||||
message: params.message,
|
||||
output: params.output,
|
||||
...(params.toolCallTitle
|
||||
? { toolCallTitle: params.toolCallTitle }
|
||||
: {}),
|
||||
...(params.stepLabel ? { stepLabel: params.stepLabel } : {})
|
||||
})
|
||||
}
|
||||
@@ -273,7 +289,8 @@ export async function runToolExecution(
|
||||
functionName: string,
|
||||
toolInput: string,
|
||||
parsedInput?: Record<string, unknown>,
|
||||
stepLabel?: string
|
||||
stepLabel?: string,
|
||||
toolCallTitle?: string
|
||||
): Promise<ToolExecutionResult> {
|
||||
const qualifiedName = `${toolkitId}.${toolId}.${functionName}`
|
||||
const requestedToolInput = toolInput
|
||||
@@ -390,6 +407,7 @@ export async function runToolExecution(
|
||||
functionName,
|
||||
toolInput: requestedToolInput,
|
||||
toolGroupId,
|
||||
...(toolCallTitle ? { toolCallTitle } : {}),
|
||||
...(stepLabel ? { stepLabel } : {})
|
||||
})
|
||||
|
||||
@@ -413,6 +431,7 @@ export async function runToolExecution(
|
||||
functionName,
|
||||
toolGroupId,
|
||||
output,
|
||||
...(toolCallTitle ? { toolCallTitle } : {}),
|
||||
...(stepLabel ? { stepLabel } : {})
|
||||
})
|
||||
return
|
||||
@@ -424,6 +443,7 @@ export async function runToolExecution(
|
||||
functionName,
|
||||
toolGroupId,
|
||||
message: progress.message,
|
||||
...(toolCallTitle ? { toolCallTitle } : {}),
|
||||
...(stepLabel ? { stepLabel } : {})
|
||||
})
|
||||
|
||||
@@ -513,6 +533,7 @@ export async function runToolExecution(
|
||||
output: toolExecutionResult.data?.output || {},
|
||||
status: effectiveStatus,
|
||||
message: effectiveMessage,
|
||||
...(toolCallTitle ? { toolCallTitle } : {}),
|
||||
...(stepLabel ? { stepLabel } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ExecutionRecord {
|
||||
function: string
|
||||
status: string
|
||||
observation: string
|
||||
toolCallTitle?: string
|
||||
stepLabel?: string
|
||||
requestedToolInput?: string
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} from '@/core/llm-manager/llm-duties/react-llm-duty/agent-context-budget'
|
||||
import {
|
||||
AGENT_MAX_ITERATIONS,
|
||||
AGENT_MAX_PARALLEL_TOOL_CALLS
|
||||
AGENT_MAX_PARALLEL_TOOL_CALLS,
|
||||
AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME
|
||||
} from '@/core/llm-manager/llm-duties/react-llm-duty/constants'
|
||||
import type {
|
||||
AgentToolTranscriptMessage,
|
||||
@@ -194,6 +195,56 @@ describe('continuous agent loop', () => {
|
||||
expect(result.intent).toBe('answer')
|
||||
})
|
||||
|
||||
it('separates a generated title from executable tool arguments', async () => {
|
||||
const executeFunction = vi.fn(
|
||||
async (
|
||||
_callable: AgentCallableFunction,
|
||||
toolInput: string
|
||||
) => ({
|
||||
execution: {
|
||||
function: callable.qualifiedName,
|
||||
status: 'success',
|
||||
observation: 'Desktop files listed.',
|
||||
requestedToolInput: toolInput
|
||||
}
|
||||
})
|
||||
)
|
||||
let modelTurn = 0
|
||||
|
||||
const result = await runAgentLoop({
|
||||
transcript: [{ role: 'user', content: 'List my desktop files.' }],
|
||||
catalog: createCatalog(),
|
||||
callModel: async () => {
|
||||
modelTurn += 1
|
||||
if (modelTurn === 1) {
|
||||
return {
|
||||
toolCalls: [
|
||||
toolCall('list-desktop', CALLABLE_TOOL_NAME, {
|
||||
query: '~/Desktop',
|
||||
[AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME]:
|
||||
'List files on ~/Desktop'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return { textContent: 'The desktop files were listed.' }
|
||||
},
|
||||
executeFunction,
|
||||
loadAgentSkill: async () => null
|
||||
})
|
||||
|
||||
expect(executeFunction).toHaveBeenCalledWith(
|
||||
callable,
|
||||
JSON.stringify({ query: '~/Desktop' }),
|
||||
'List files on ~/Desktop'
|
||||
)
|
||||
expect(result.executionHistory[0]).toMatchObject({
|
||||
toolCallTitle: 'List files on ~/Desktop',
|
||||
requestedToolInput: JSON.stringify({ query: '~/Desktop' })
|
||||
})
|
||||
})
|
||||
|
||||
it('compacts context and disables reasoning for one empty-output recovery', async () => {
|
||||
const callModel = vi
|
||||
.fn()
|
||||
@@ -1082,14 +1133,15 @@ describe('continuous agent loop', () => {
|
||||
toolDescription: 'Control robot positioning.'
|
||||
}
|
||||
])
|
||||
const parameters = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false
|
||||
}
|
||||
coreMocks.getToolFunctions.mockReturnValue({
|
||||
home: {
|
||||
description: 'Return the robot to its home position.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false
|
||||
}
|
||||
parameters
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1099,6 +1151,23 @@ describe('continuous agent loop', () => {
|
||||
expect(toolNames).not.toContain(AGENT_TOOLKIT_LOADER_NAME)
|
||||
expect(toolNames).toContain('device_control__robot__home')
|
||||
expect(catalog.loadedToolkitIds).toEqual(new Set(['device_control']))
|
||||
|
||||
const homeTool = catalog.tools.find(
|
||||
(tool) => tool.function.name === 'device_control__robot__home'
|
||||
)
|
||||
expect(homeTool?.function.parameters).toMatchObject({
|
||||
properties: {
|
||||
[AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME]: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
required: [AGENT_TOOL_CALL_TITLE_ARGUMENT_NAME]
|
||||
})
|
||||
expect(parameters).toEqual({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds large observations and prunes inactive schemas near the context limit', () => {
|
||||
|
||||
Reference in New Issue
Block a user