Fix Tool Calling
Release rolling / release (push) Successful in 10m8s

This commit is contained in:
2026-08-18 20:44:36 -04:00
parent d3aa66b0cc
commit 9d53253e48
10 changed files with 2697 additions and 444 deletions
@@ -116,13 +116,56 @@ async function bareAgentStreamChatCompletions(opts) {
if (j.type === 'response.output_text.delta' && typeof j.delta === 'string') {
onEvent({ type: 'delta_content', content: j.delta })
}
if (j.type === 'response.output_item.added') {
const item =
j.item && typeof j.item === 'object'
? /** @type {Record<string, unknown>} */ (j.item)
: null
if (
item &&
(item.type === 'function_call' || item.type === 'custom_tool_call')
) {
const idx =
typeof j.output_index === 'number' ? j.output_index : 0
const name = typeof item.name === 'string' ? item.name : ''
const args =
typeof item.arguments === 'string' ? item.arguments : ''
const id =
typeof item.call_id === 'string'
? item.call_id
: typeof item.id === 'string'
? item.id
: ''
if (name || args) {
onEvent({
type: 'delta_tool_calls',
tool_calls: [
{
index: idx,
id,
function: { name, arguments: args }
}
]
})
}
}
}
if (
j.type === 'response.function_call_arguments.delta' &&
typeof j.delta === 'string'
) {
const idx = typeof j.output_index === 'number' ? j.output_index : 0
const name = typeof j.name === 'string' ? j.name : ''
const id = typeof j.item_id === 'string' ? j.item_id : ''
onEvent({
type: 'delta_tool_calls',
tool_calls: [{ index: 0, function: { arguments: j.delta } }]
tool_calls: [
{
index: idx,
id,
function: { name, arguments: j.delta }
}
]
})
}
}
@@ -139,6 +182,10 @@ async function bareAgentStreamChatCompletions(opts) {
typeof ch0?.finish_reason === 'string' ? ch0.finish_reason : ''
const usage =
typeof j.usage === 'object' && j.usage ? j.usage : undefined
const message =
ch0 && typeof ch0.message === 'object'
? /** @type {Record<string, unknown>} */ (ch0.message)
: null
if (usage) {
onEvent({ type: 'usage', usage })
@@ -191,6 +238,23 @@ async function bareAgentStreamChatCompletions(opts) {
}
}
if (message) {
const msgTools = message.tool_calls
if (Array.isArray(msgTools) && msgTools.length) {
onEvent({
type: 'delta_tool_calls',
tool_calls: msgTools
})
}
const legacy = message.function_call
if (legacy && typeof legacy === 'object') {
onEvent({
type: 'delta_tool_calls',
tool_calls: [{ index: 0, function: legacy }]
})
}
}
if (finishReason)
onEvent({
type: 'finish_reason',
@@ -111,8 +111,76 @@ function bareAgentQvacGetProfile(id) {
return BARE_AGENT_QVAC_PROFILES[key] || BARE_AGENT_QVAC_PROFILES.recommended
}
const BARE_AGENT_QVAC_PROP_TYPES = Object.freeze([
'string',
'number',
'integer',
'boolean',
'object',
'array'
])
/**
* Flatten OpenAI nested tool defs → QVAC flat shape.
* QVAC toolSchema only allows { type, description?, enum? } per property.
* Nested items/properties/defaults make the whole tools array fail validation
* and the SDK then runs the completion with tools omitted.
* @param {unknown} params
* @returns {{ type: 'object', properties: Record<string, unknown>, required?: string[] }}
*/
function bareAgentSanitizeQvacToolParameters(params) {
const src =
params && typeof params === 'object'
? /** @type {Record<string, unknown>} */ (params)
: {}
const rawProps =
src.properties && typeof src.properties === 'object' && !Array.isArray(src.properties)
? /** @type {Record<string, unknown>} */ (src.properties)
: {}
/** @type {Record<string, unknown>} */
const properties = {}
for (const key of Object.keys(rawProps)) {
const v = rawProps[key]
const prop = v && typeof v === 'object' && !Array.isArray(v)
? /** @type {Record<string, unknown>} */ (v)
: {}
let type = typeof prop.type === 'string' ? prop.type : 'string'
if (BARE_AGENT_QVAC_PROP_TYPES.indexOf(type) === -1) type = 'string'
/** @type {Record<string, unknown>} */
const next = { type: type }
if (typeof prop.description === 'string' && prop.description) {
next.description = prop.description
}
if (Array.isArray(prop.enum)) {
const en = prop.enum.filter(function (x) {
return (
typeof x === 'string' ||
typeof x === 'number' ||
typeof x === 'boolean' ||
x === null
)
})
if (en.length) next.enum = en
}
properties[key] = next
}
/** @type {{ type: 'object', properties: Record<string, unknown>, required?: string[] }} */
const out = { type: 'object', properties: properties }
if (Array.isArray(src.required)) {
const req = src.required
.map(function (x) {
return String(x || '')
})
.filter(function (k) {
return k && Object.prototype.hasOwnProperty.call(properties, k)
})
if (req.length) out.required = req
}
return out
}
/**
* Flatten OpenAI nested tool defs → QVAC flat shape, then strip property
* fields the QVAC SDK schema rejects (items, nested properties, default, …).
* @param {unknown[]} tools
* @returns {unknown[]}
*/
@@ -125,30 +193,118 @@ function bareAgentFlattenToolsForQvac(tools) {
const o = /** @type {Record<string, unknown>} */ (t)
if (o.function && typeof o.function === 'object') {
const fn = /** @type {Record<string, unknown>} */ (o.function)
const name = typeof fn.name === 'string' ? fn.name : ''
if (!name) continue
out.push({
type: 'function',
name: typeof fn.name === 'string' ? fn.name : '',
name: name,
description: typeof fn.description === 'string' ? fn.description : '',
parameters:
fn.parameters && typeof fn.parameters === 'object'
? fn.parameters
: { type: 'object', properties: {} }
parameters: bareAgentSanitizeQvacToolParameters(fn.parameters)
})
continue
}
if (typeof o.name === 'string') {
if (typeof o.name === 'string' && o.name) {
out.push({
type: 'function',
name: o.name,
description: typeof o.description === 'string' ? o.description : '',
parameters:
o.parameters && typeof o.parameters === 'object'
? o.parameters
: { type: 'object', properties: {} }
parameters: bareAgentSanitizeQvacToolParameters(o.parameters)
})
}
}
return out.filter((x) => x && typeof x === 'object' && /** @type {any} */ (x).name)
return out
}
/**
* QVAC completion history is `{ role, content: string }` only. OpenAI-style
* `content: null` + `tool_calls` fails request validation and the turn
* never reaches the model (so the harness looks like it "stopped using tools").
* @param {unknown[]} history
* @returns {{ role: string, content: string }[]}
*/
function bareAgentSanitizeHistoryForQvac(history) {
if (!Array.isArray(history)) return []
/** @type {{ role: string, content: string }[]} */
const out = []
for (const raw of history) {
if (!raw || typeof raw !== 'object') continue
const m = /** @type {Record<string, unknown>} */ (raw)
const role = typeof m.role === 'string' && m.role ? m.role : 'user'
let content = ''
if (typeof m.content === 'string') content = m.content
else if (Array.isArray(m.content)) {
const parts = []
for (const part of m.content) {
if (typeof part === 'string') parts.push(part)
else if (part && typeof part === 'object') {
const o = /** @type {Record<string, unknown>} */ (part)
if (typeof o.text === 'string') parts.push(o.text)
else if (typeof o.content === 'string') parts.push(o.content)
}
}
content = parts.join('')
} else if (m.content != null && typeof m.content !== 'object') {
content = String(m.content)
}
if (Array.isArray(m.tool_calls) && m.tool_calls.length) {
const chunks = []
for (const tc of m.tool_calls) {
if (!tc || typeof tc !== 'object') continue
const o = /** @type {Record<string, unknown>} */ (tc)
const fn =
o.function && typeof o.function === 'object'
? /** @type {Record<string, unknown>} */ (o.function)
: o
const name = typeof fn.name === 'string' ? fn.name : ''
if (!name) continue
let args = fn.arguments
if (args != null && typeof args !== 'string') {
try {
args = JSON.stringify(args)
} catch {
args = '{}'
}
}
if (typeof args !== 'string' || !args) args = '{}'
let argsObj = args
try {
const parsed = JSON.parse(args)
argsObj = JSON.stringify(parsed && typeof parsed === 'object' ? parsed : {})
} catch {
argsObj = '{}'
}
chunks.push(
'<tool_call>{"name":' +
JSON.stringify(name) +
',"arguments":' +
argsObj +
'}</tool_call>'
)
}
if (chunks.length) {
const serialized = chunks.join('\n')
content = content ? content + '\n' + serialized : serialized
}
}
out.push({ role: role, content: content })
}
return out
}
/**
* Match QVAC `detectToolDialectFromName` so we do not override auto-detect
* with the wrong parser (e.g. LFM/DeepSeek).
* @param {string} [modelId]
* @returns {'hermes'|'qwen35'|'gemma4'|'harmony'|'pythonic'|'dsml'}
*/
function bareAgentQvacDetectToolDialect(modelId) {
const tag = String(modelId || '').toLowerCase()
if (/qwen3[._-]?[56](?![a-z0-9])/.test(tag)) return 'qwen35'
if (/gemma[-_]?4(?=[^a-z0-9]|$)/.test(tag)) return 'gemma4'
if (/gpt[_-]?oss/.test(tag)) return 'harmony'
if (/deepseek[-_. ]?v(?:4|3[._-]?2)(?![0-9])/.test(tag)) return 'dsml'
if (/lfm[_-]?\d/.test(tag)) return 'pythonic'
return 'hermes'
}
/**
@@ -431,6 +431,91 @@ function bareAgentFinalizeToolCalls(acc) {
return arr
}
/**
* Recover tool calls the provider streamed as plain text (Hermes / Qwen XML)
* instead of `delta_tool_calls`. Today's models often do this when the SDK
* drops tools or picks the wrong dialect.
* @param {string} text
* @returns {{ index: number, id: string, function: { name: string, arguments: string } }[]}
*/
function bareAgentExtractToolCallsFromText(text) {
const src = String(text || '')
if (!src) return []
/** @type {{ index: number, id: string, function: { name: string, arguments: string } }[]} */
const out = []
let idx = 0
/**
* @param {string} name
* @param {string} args
*/
function push(name, args) {
const n = String(name || '').trim()
if (!n) return
let a = String(args == null ? '' : args).trim()
if (!a) a = '{}'
else if (a[0] !== '{' && a[0] !== '[') {
try {
JSON.parse(a)
} catch {
a = JSON.stringify({ value: a })
}
}
out.push({
index: idx,
id: 'text_call_' + idx,
function: { name: n, arguments: a }
})
idx += 1
}
const hermes = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi
let m
while ((m = hermes.exec(src))) {
const inner = String(m[1] || '').trim()
const fnXml = /^<function=([^>]+)>([\s\S]*?)<\/function>$/i.exec(inner)
if (fnXml) {
const name = fnXml[1]
/** @type {Record<string, string>} */
const args = {}
const paramRe = /<parameter=([^>]+)>([\s\S]*?)<\/parameter>/gi
let pm
while ((pm = paramRe.exec(fnXml[2] || ''))) {
args[String(pm[1] || '').trim()] = String(pm[2] || '')
}
push(name, JSON.stringify(args))
continue
}
try {
const j = JSON.parse(inner)
if (j && typeof j === 'object' && typeof j.name === 'string') {
const args =
j.arguments != null
? typeof j.arguments === 'string'
? j.arguments
: JSON.stringify(j.arguments)
: '{}'
push(j.name, args)
continue
}
} catch {
/* not JSON */
}
}
return out
}
/**
* @param {string} text
*/
function bareAgentStripToolCallsFromText(text) {
return String(text || '')
.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/**
* @param {Record<string, unknown>} cfg
*/
@@ -1931,6 +2016,23 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
/** @type {unknown} */
let usageOut = null
let finishReason = ''
let recoveredFromText = false
function recoverToolsFromAssistantText() {
if (recoveredFromText) return
recoveredFromText = true
if (typeof bareAgentExtractToolCallsFromText !== 'function') return
const extracted = bareAgentExtractToolCallsFromText(assistantContent)
if (!toolAcc.size && extracted.length) {
for (const tc of extracted) bareAgentMergeToolCallDelta(toolAcc, tc)
appendProgress(
'recovered_tool_calls_from_text n=' + String(extracted.length)
)
}
if (extracted.length) {
assistantContent = bareAgentStripToolCallsFromText(assistantContent)
}
}
const hideThinkEnv = String(envBag.BARE_OS_AGENT_HIDE_THINK || '')
.trim()
.toLowerCase()
@@ -2259,13 +2361,21 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
)
}
}
const qvacHistory =
typeof bareAgentSanitizeHistoryForQvac === 'function'
? bareAgentSanitizeHistoryForQvac(messages)
: messages
await ctx.bareOsQvacComplete({
history: messages,
history: qvacHistory,
tools: qvacToolsForTurn,
stream: Boolean(configRef.current.stream !== false),
captureThinking: !hideThink || reasoningSettings.enabled,
modelSrc,
toolsEnabled: true,
toolDialect:
typeof bareAgentQvacDetectToolDialect === 'function'
? bareAgentQvacDetectToolDialect(modelSrc)
: undefined,
ctxSize,
device: deviceOpts.device,
mainGpu: deviceOpts.mainGpu,
@@ -2277,6 +2387,7 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
clearStatusLine()
thinkSplit.flush()
sealThinkIfNeeded()
recoverToolsFromAssistantText()
paintReplyMarkdown()
try {
detachThinkKeys()
@@ -2327,6 +2438,7 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
clearStatusLine()
thinkSplit.flush()
sealThinkIfNeeded()
recoverToolsFromAssistantText()
paintReplyMarkdown()
try {
detachThinkKeys()
@@ -2351,7 +2463,18 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
break
}
const toolCallsArr = bareAgentFinalizeToolCalls(toolAcc)
let toolCallsArr = bareAgentFinalizeToolCalls(toolAcc)
if (!toolCallsArr.length && typeof bareAgentExtractToolCallsFromText === 'function') {
const extracted = bareAgentExtractToolCallsFromText(assistantContent)
if (extracted.length) {
for (const tc of extracted) bareAgentMergeToolCallDelta(toolAcc, tc)
toolCallsArr = bareAgentFinalizeToolCalls(toolAcc)
assistantContent = bareAgentStripToolCallsFromText(assistantContent)
appendProgress(
'recovered_tool_calls_from_text n=' + String(toolCallsArr.length)
)
}
}
const hasTools = toolCallsArr.length > 0
if (!hasTools && finishReason === 'tool_calls') {
appendProgress(
@@ -2363,7 +2486,7 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
/** @type {Record<string, unknown>} */
const assistantMsg = {
role: 'assistant',
content: assistantContent || null,
content: assistantContent || '',
tool_calls: hasTools ? toolCallsArr : undefined
}
messages.push(assistantMsg)
+1 -1
View File
@@ -7,7 +7,7 @@
"description": "Build JS /bin utilities for bare-operating-system (concat + stage to kernel/)",
"scripts": {
"build": "node ./scripts/ensure-man-pages.mjs && node ./build.mjs",
"test": "node ./test/clear-sequence.test.mjs && node ./test/init-discord.test.mjs && node ./test/help-bin-list.test.mjs && node ./test/whois-rdap.test.mjs && node ./test/edit-key-parse.test.mjs && node ./test/edit-teardown.test.mjs && node ./test/edit-tui-sdk.test.mjs && node ./test/baresay-say-bundle.test.mjs && node ./test/baretop-bundle.test.mjs && node ./test/baretop-tui-sdk.test.mjs && node ./test/baretop-fixture.test.mjs && node ./test/baretop-compose.test.mjs && node ./test/baretop-incremental.test.mjs && node ./test/baretop-interaction-latency.test.mjs && node ./test/baretop-missing-signals.test.mjs && node ./test/baretop-stress-snapshot.test.mjs && node ./test/baretop-ui-helpers.test.mjs && node ./test/baretop-perf-baseline.test.mjs && node ./test/irc-parse.test.mjs && node ./test/irc-client.test.mjs && node ./test/irc-dial.test.mjs && node ./test/irc-commands.test.mjs && node ./test/irc-tui-sdk.test.mjs && node ./test/summon-engine.test.mjs && node ./test/summon-tui-sdk.test.mjs && node ./test/posix-test-int-compare.test.mjs && node ./test/posix-test-bracket-argv.test.mjs && node ./test/posix-utils-edge.test.mjs && node ./test/posix-golden-issue7.test.mjs && node ./test/getconf-pathconf-union-mirror.test.mjs && node ./test/getconf-posix-shm.test.mjs && node ./test/awk-sed-posix-smoke.test.mjs && node ./test/xattr-acl-utils.test.mjs && node ./test/xcu-issue7-sweep.test.mjs && node ./test/expand-tab-stops.test.mjs && node ./test/pkg-swarm-index-pathcap.test.mjs && node ./test/sha224-sha384-sum.test.mjs && node ./test/hardening.test.mjs && node ./test/agent-sse-parse.test.mjs && node ./test/agent-web-fetch.test.mjs && node ./test/agent-helpers.test.mjs && node ./test/agent-tools-run-command.test.mjs && node ./test/agent-workspace.test.mjs && node ./test/agent-skills.test.mjs && node ./test/agent-config-surface.test.mjs && node ./test/agent-grok-port.test.mjs && node ./test/agent-trim-ctx.test.mjs && node ./test/agent-qvac.test.mjs && node ./test/agent-models.test.mjs && node ./test/agent-live-model.test.mjs && node ./test/telnet-protocol.test.mjs && node ./test/telnet-cli.test.mjs && node ./test/login.test.mjs && node ./test/p2p-suite-bundles.test.mjs && node ./test/p2p-suite-tui-sdk.test.mjs && node ./test/chat-tui-sdk.test.mjs && node ./test/swarmtop-peer-visibility.test.mjs",
"test": "node ./test/clear-sequence.test.mjs && node ./test/init-discord.test.mjs && node ./test/help-bin-list.test.mjs && node ./test/whois-rdap.test.mjs && node ./test/edit-key-parse.test.mjs && node ./test/edit-teardown.test.mjs && node ./test/edit-tui-sdk.test.mjs && node ./test/baresay-say-bundle.test.mjs && node ./test/baretop-bundle.test.mjs && node ./test/baretop-tui-sdk.test.mjs && node ./test/baretop-fixture.test.mjs && node ./test/baretop-compose.test.mjs && node ./test/baretop-incremental.test.mjs && node ./test/baretop-interaction-latency.test.mjs && node ./test/baretop-missing-signals.test.mjs && node ./test/baretop-stress-snapshot.test.mjs && node ./test/baretop-ui-helpers.test.mjs && node ./test/baretop-perf-baseline.test.mjs && node ./test/irc-parse.test.mjs && node ./test/irc-client.test.mjs && node ./test/irc-dial.test.mjs && node ./test/irc-commands.test.mjs && node ./test/irc-tui-sdk.test.mjs && node ./test/summon-engine.test.mjs && node ./test/summon-tui-sdk.test.mjs && node ./test/posix-test-int-compare.test.mjs && node ./test/posix-test-bracket-argv.test.mjs && node ./test/posix-utils-edge.test.mjs && node ./test/posix-golden-issue7.test.mjs && node ./test/getconf-pathconf-union-mirror.test.mjs && node ./test/getconf-posix-shm.test.mjs && node ./test/awk-sed-posix-smoke.test.mjs && node ./test/xattr-acl-utils.test.mjs && node ./test/xcu-issue7-sweep.test.mjs && node ./test/expand-tab-stops.test.mjs && node ./test/pkg-swarm-index-pathcap.test.mjs && node ./test/sha224-sha384-sum.test.mjs && node ./test/hardening.test.mjs && node ./test/agent-sse-parse.test.mjs && node ./test/agent-web-fetch.test.mjs && node ./test/agent-helpers.test.mjs && node ./test/agent-tools-run-command.test.mjs && node ./test/agent-workspace.test.mjs && node ./test/agent-skills.test.mjs && node ./test/agent-config-surface.test.mjs && node ./test/agent-grok-port.test.mjs && node ./test/agent-trim-ctx.test.mjs && node ./test/agent-qvac.test.mjs && node ./test/agent-tool-calls.test.mjs && node ./test/agent-models.test.mjs && node ./test/agent-live-model.test.mjs && node ./test/telnet-protocol.test.mjs && node ./test/telnet-cli.test.mjs && node ./test/login.test.mjs && node ./test/p2p-suite-bundles.test.mjs && node ./test/p2p-suite-tui-sdk.test.mjs && node ./test/chat-tui-sdk.test.mjs && node ./test/swarmtop-peer-visibility.test.mjs",
"perf:baretop": "node ./test/baretop-perf-baseline.test.mjs"
}
}
@@ -10,12 +10,44 @@ function loadAgentQvacHelpers() {
vm.createContext(sandbox)
vm.runInContext(
QVAC_SRC +
'\n;this.__exports = { bareAgentFlattenToolsForQvac, bareAgentQvacGetProfile, bareAgentQvacProfileList, bareAgentResolveBackend, bareAgentQvacBridgeAvailable, bareAgentQvacResolveCtxSize, bareAgentQvacResolveDeviceOpts, bareAgentQvacModelCardCtxSize, bareAgentSanitizeConfigForBackend, bareAgentRestGetProvider, bareAgentRestProviderList, bareAgentIsQvacModelId, bareAgentMaskSecretPreview, BARE_AGENT_QVAC_CTX_QWEN3, BARE_AGENT_QVAC_CTX_LLAMA32_1B }',
'\n;this.__exports = { bareAgentFlattenToolsForQvac, bareAgentSanitizeHistoryForQvac, bareAgentQvacDetectToolDialect, bareAgentQvacGetProfile, bareAgentQvacProfileList, bareAgentResolveBackend, bareAgentQvacBridgeAvailable, bareAgentQvacResolveCtxSize, bareAgentQvacResolveDeviceOpts, bareAgentQvacModelCardCtxSize, bareAgentSanitizeConfigForBackend, bareAgentRestGetProvider, bareAgentRestProviderList, bareAgentIsQvacModelId, bareAgentMaskSecretPreview, BARE_AGENT_QVAC_CTX_QWEN3, BARE_AGENT_QVAC_CTX_LLAMA32_1B }',
sandbox
)
return sandbox.__exports
}
test('full agent tool list flattens to QVAC-safe property schemas', async (t) => {
const defs = readFileSync(
new URL('../lib/agent/agent-tool-definitions.js', import.meta.url),
'utf8'
)
const sandbox = { console }
vm.createContext(sandbox)
vm.runInContext(
QVAC_SRC +
'\n' +
defs +
'\n;this.__exports = { bareAgentFlattenToolsForQvac, bareAgentToolDefinitions }',
sandbox
)
const { bareAgentFlattenToolsForQvac, bareAgentToolDefinitions } = sandbox.__exports
const flat = bareAgentFlattenToolsForQvac(bareAgentToolDefinitions())
t.ok(flat.length > 20)
const allowed = { type: 1, description: 1, enum: 1 }
for (const tool of flat) {
t.ok(tool.name, 'tool missing name')
t.is(tool.type, 'function')
t.ok(tool.parameters && tool.parameters.type === 'object')
const props = tool.parameters.properties || {}
for (const key of Object.keys(props)) {
const prop = props[key]
for (const pk of Object.keys(prop)) {
t.ok(allowed[pk], tool.name + '.' + key + ' has forbidden key ' + pk)
}
}
}
})
test('flatten OpenAI nested tools for QVAC', async (t) => {
const { bareAgentFlattenToolsForQvac } = loadAgentQvacHelpers()
const flat = bareAgentFlattenToolsForQvac([
@@ -35,6 +67,88 @@ test('flatten OpenAI nested tools for QVAC', async (t) => {
t.is(flat[0].function, undefined)
})
test('flatten strips nested JSON-schema fields QVAC rejects', async (t) => {
const { bareAgentFlattenToolsForQvac } = loadAgentQvacHelpers()
const flat = bareAgentFlattenToolsForQvac([
{
type: 'function',
function: {
name: 'todo_write',
description: 'todos',
parameters: {
type: 'object',
properties: {
todos: {
type: 'array',
description: 'items',
items: { type: 'object', properties: { id: { type: 'string' } } }
},
merge: { type: 'boolean', default: true }
},
required: ['todos', 'missing']
}
}
}
])
t.is(flat.length, 1)
const todos = flat[0].parameters.properties.todos
t.is(todos.type, 'array')
t.is(todos.description, 'items')
t.absent(todos.items)
t.absent(flat[0].parameters.properties.merge.default)
t.alike(flat[0].parameters.required, ['todos'])
})
test('sanitize QVAC history stringifies tool_calls and never sends null content', async (t) => {
const { bareAgentSanitizeHistoryForQvac } = loadAgentQvacHelpers()
const out = bareAgentSanitizeHistoryForQvac([
{ role: 'system', content: 'sys' },
{
role: 'assistant',
content: null,
tool_calls: [
{
id: 'c1',
function: { name: 'read_file', arguments: '{"path":"/x"}' }
}
]
},
{ role: 'tool', tool_call_id: 'c1', content: 'ok' }
])
t.is(out.length, 3)
t.is(out[1].role, 'assistant')
t.ok(out[1].content.includes('<tool_call>'))
t.ok(out[1].content.includes('read_file'))
t.absent('tool_calls' in out[1])
t.is(out[2].content, 'ok')
})
test('sanitize QVAC history keeps prose and serializes tool_calls', async (t) => {
const { bareAgentSanitizeHistoryForQvac } = loadAgentQvacHelpers()
const out = bareAgentSanitizeHistoryForQvac([
{
role: 'assistant',
content: 'Working.',
tool_calls: [
{ function: { name: 'read_file', arguments: '{"path":"/x"}' } }
]
}
])
t.ok(out[0].content.includes('Working.'))
t.ok(out[0].content.includes('<tool_call>'))
t.ok(out[0].content.includes('read_file'))
})
test('detect QVAC tool dialect from model id', async (t) => {
const { bareAgentQvacDetectToolDialect } = loadAgentQvacHelpers()
t.is(bareAgentQvacDetectToolDialect('QWEN3_1_7B_INST_Q4'), 'hermes')
t.is(bareAgentQvacDetectToolDialect('QWEN3_5_4B_MULTIMODAL_Q4_K_M'), 'qwen35')
t.is(bareAgentQvacDetectToolDialect('GEMMA4_2B_MULTIMODAL_Q4_K_M'), 'gemma4')
t.is(bareAgentQvacDetectToolDialect('GPT_OSS_20B_INST_Q4_K_M'), 'harmony')
t.is(bareAgentQvacDetectToolDialect('LFM2_1_2B_Q4_K_M'), 'pythonic')
t.is(bareAgentQvacDetectToolDialect('DEEPSEEK_V3_2_CHAT'), 'dsml')
})
test('qvac profiles include recommended + lite', async (t) => {
const { bareAgentQvacProfileList, bareAgentQvacGetProfile } = loadAgentQvacHelpers()
const list = bareAgentQvacProfileList()
@@ -55,6 +55,28 @@ test('finalize drops duplicate name+args when ids differ', () => {
assert.equal(out.length, 1)
})
test('extract hermes <tool_call> JSON from assistant text', () => {
const { bareAgentExtractToolCallsFromText, bareAgentStripToolCallsFromText } =
loadToolCallFns()
const text =
'Working.\n<tool_call>{"name":"read_file","arguments":{"path":"/home/guest/x"}}</tool_call>'
const out = bareAgentExtractToolCallsFromText(text)
assert.equal(out.length, 1)
assert.equal(out[0].function.name, 'read_file')
assert.ok(String(out[0].function.arguments).includes('/home/guest/x'))
assert.equal(bareAgentStripToolCallsFromText(text), 'Working.')
})
test('extract qwen3.5 XML tool_call from assistant text', () => {
const { bareAgentExtractToolCallsFromText } = loadToolCallFns()
const text =
'<tool_call><function=list_directory><parameter=path>/home</parameter></function></tool_call>'
const out = bareAgentExtractToolCallsFromText(text)
assert.equal(out.length, 1)
assert.equal(out[0].function.name, 'list_directory')
assert.equal(JSON.parse(out[0].function.arguments).path, '/home')
})
test('finalize keeps same tool with different args', () => {
const { bareAgentMergeToolCallDelta, bareAgentFinalizeToolCalls } = loadToolCallFns()
const acc = new Map()