feat(agent mode): add tool call goal titles

This commit is contained in:
louistiti
2026-08-07 21:38:45 +08:00
parent ed5cfc5b9c
commit ff569c507f
7 changed files with 205 additions and 17 deletions
+5 -1
View File
@@ -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
}
+75 -6
View File
@@ -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', () => {