Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ await build({
format: "esm",
target: "node22",
// QVAC is native + spawns its own Bare worker — never bundle it.
external: ["@qvac/sdk", "@qvac/*"],
external: ["@qvac/sdk", "@qvac/*", "@modelcontextprotocol/*"],
// Bundle-only: give WDK the pure-JS sodium (safe in the Node main process),
// without touching the native sodium-native that QVAC's worker needs.
alias: { "sodium-native": "sodium-javascript" },
Expand Down
42 changes: 42 additions & 0 deletions src/agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,48 @@ export async function completeWithMcp(
}
}

/**
* Run a completion with native tool-calling.
* Returns { text, toolCalls, toolErrors } where toolCalls is an array of { id, name, arguments }
* and toolErrors is an array of { code, message, raw? } — the `error` payload of the
* SDK's `toolError` event (locked @qvac/sdk 0.14.1 emits `toolError` on `run.events`;
* `toolCallError` belongs to the separate tool-call stream and is accepted here only
* as a fallback so a dialect change can't silently drop failures).
*
* `runCompletion` is a test seam (same pattern as completeWithMcp): it defaults to
* QVAC's own `completion()`, but a caller can inject a fake `{ events }` producer to
* drive the exact event-parsing code below without a live model.
*/
export async function completeWithTools(history, tools, onToken, { runCompletion } = {}) {
const doCompletion = runCompletion ?? (await qvac()).completion;
const run = doCompletion({ modelId, history, tools, stream: true }, { timeout: 300_000 });
let text = "";
const toolCalls = [];
const toolErrors = [];

try {
for await (const event of run.events) {
if (event.type === "contentDelta") {
text += event.text;
if (onToken) onToken(event.text);
} else if (event.type === "toolCall") {
toolCalls.push(event.call);
} else if (event.type === "toolError") {
toolErrors.push(event.error);
} else if (event.type === "toolCallError") {
// Fallback: not emitted on run.events by SDK 0.14.1, but preserve the
// message rather than dropping it if a future SDK/dialect does.
toolErrors.push(event.error ?? event);
}
}
} catch (err) {
// A model-side error must not crash the agent, but surface it to the caller.
throw err;
}

return { text, toolCalls, toolErrors };
}

export async function unloadBrain() {
if (!modelId) return;
const { unloadModel } = await qvac();
Expand Down
141 changes: 104 additions & 37 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import { addressBookWarnings, formatRecipient, safeEcho } from "./addressBook.mj
import { loadMcpConfig, connectMcpServers, disconnectMcpServers, summarizeMcpToolResult } from "./mcp.mjs";
import {
ACTIONS,
systemPrompt,
selectSystemPrompt,
parseAction,
runAction,
isRefusal,
Expand All @@ -91,7 +91,30 @@ import {
needsRecipient,
buildSwapPreview,
lockBestSwap,
getToolDefinitions,
dispatchToolCall,
} from "./tools.mjs";
import { runNativeToolLoop } from "./nativeToolLoop.mjs";

/**
* Build the REPL's initial history with the prompt matching the enabled
* protocol: native tool-calling gets nativeSystemPrompt() (function tools, no
* JSON instruction); v0 gets systemPrompt() (one JSON action line). Exported so
* tests drive the real CLI selection instead of re-implementing it.
*/
export function buildInitialHistory() {
return [{ role: "system", content: selectSystemPrompt(config.useNativeTools) }];
}

/**
* Fold a native-turn failure back into the outer scripted flag — the exact step
* processLine performs before the scripted `process.exit(hadFailure ? 1 : 0)`.
* Exported so the CLI exit-path regression drives the real mapping.
*/
export function propagateHadFailure(hadFailureRef, outerHadFailure) {
if (hadFailureRef?.value) return true;
return outerHadFailure;
}

// ── color (no deps) ─────────────────────────────────────────────────────────
// Gated on a real TTY + respects NO_COLOR, so piped/CI output stays clean text.
Expand Down Expand Up @@ -150,6 +173,7 @@ function statusBlock() {
);
row("rpc", c.gray(config.chain.rpcUrl));
row("gas", `${dot} ${gas}`);
row("tools", config.useNativeTools ? c.green("native") + c.dim(" · structured tool-calling") : c.cyan("v0") + c.dim(" · JSON protocol"));
if (mcpClients.length) {
row("mcp", c.green(`${mcpClients.length} server${mcpClients.length === 1 ? "" : "s"}`) + c.dim(" · " + mcpClients.map((s) => s.name).join(", ")));
}
Expand Down Expand Up @@ -241,12 +265,20 @@ async function invokeMcpToolCall(call) {
}
}

/** Execute a parsed action, confirming writes. Returns nothing (prints results). */
/**
* Execute a parsed action through the full safety boundary (recipient resolution,
* policy check, preview, mainnet ack, y/N confirmation) for writes; reads run
* directly. Returns the printable result/refusal string, or null when the action
* is `none` (model wants to just chat — its text has already streamed).
*
* The string is for the caller to put in a tool-result message, log, or test
* assertion. Side effects (printing, session-spend accounting) happen here.
*/
async function handleAction(action) {
let resolved = null;
let preparedToken = null;
let swapPreview = null;
if (action.action === "none") return false;
if (action.action === "none") return null;
if (isWrite(action.action)) {
if (needsRecipient(action.action)) {
// Resolve the recipient ONCE, before anything is shown, and hold it for the whole flow:
Expand All @@ -259,7 +291,7 @@ async function handleAction(action) {
if (!prep.ok) {
println(c.red(` Refused: ${prep.reason}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${prep.reason}`;
}
resolved = prep.recipient;
if (action.action === "send_token") preparedToken = prep;
Expand All @@ -272,27 +304,27 @@ async function handleAction(action) {
} catch (err) {
println(c.red(` Refused: ${err.message}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${err.message}`;
}
if (preview.error) {
println(c.red(` Refused: ${preview.error}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${preview.error}`;
}
println("\n " + c.yellow(preview.block.replace(/\n/g, "\n ")));
if (!(await confirmMainnetOnce())) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
if (!(await confirm(" confirm?"))) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
swapPreview = await lockBestSwap(preview);
if (swapPreview.error) {
println(c.red(` Refused: ${swapPreview.error}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${swapPreview.error}`;
}
} else if (action.action === "send_mon") {
let preview;
Expand All @@ -301,19 +333,19 @@ async function handleAction(action) {
} catch (err) {
println(c.red(` Refused: ${err.message}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${err.message}`;
}
// Quoted against the bare address; shown with the alias beside it, so the operator
// approves the same thing the book produced.
const block = renderSendPreview({ ...preview, to: formatRecipient(resolved) });
println("\n " + c.yellow(block.replace(/\n/g, "\n ")));
if (!(await confirmMainnetOnce())) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
if (!(await confirm(" confirm?"))) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
} else if (action.action === "send_token") {
let preview;
Expand All @@ -322,32 +354,32 @@ async function handleAction(action) {
} catch (err) {
println(c.red(` Refused: ${err.message}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${err.message}`;
}
if (!preview.ok) {
println(c.red(` Refused: ${preview.reason}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: ${preview.reason}`;
}
const block = renderTokenSendPreview({ ...preview, to: formatRecipient(resolved) });
println("\n " + c.yellow(block.replace(/\n/g, "\n ")));
if (!(await confirmMainnetOnce())) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
if (!(await confirm(" confirm?"))) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
} else {
println("\n " + c.yellow(describeAction(action, resolved)));
if (!(await confirmMainnetOnce())) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
if (!(await confirm(" confirm?"))) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
}
}
Expand All @@ -361,16 +393,16 @@ async function handleAction(action) {
const safe = safeEcho(action.index, 40);
println("\n " + c.red(`Refused: "${safe}" is not a valid account index.`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
return `Refused: "${safe}" is not a valid account index.`;
}
println("\n " + c.yellow(describeAction(action)));
if (!(await confirmMainnetOnce())) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
if (!(await confirm(" confirm?"))) {
println(c.dim(" cancelled.") + "\n");
return true;
return "cancelled";
}
}
try {
Expand All @@ -390,11 +422,13 @@ async function handleAction(action) {
// A refusal returned as a string is a failure too, same as the throw below;
// in scripted mode it must set the exit code so a CI script can see it.
if (SCRIPTED && refused) hadFailure = true;
return out;
} catch (err) {
console.log(c.red(` error: ${err.message}`) + "\n");
const message = `error: ${err.message}`;
console.log(c.red(` ${message}`) + "\n");
if (SCRIPTED) hadFailure = true;
return message;
}
return true;
}

async function handleSlash(line) {
Expand Down Expand Up @@ -596,27 +630,51 @@ async function main() {
return true;
}

// v0 JSON protocol: the model's raw output (thinking + JSON) streams dimmed
// to the conversational surface; the executed result prints bright on stdout.
let raw = "";
// Native tool-calling writes must go through the SAME handleAction the v0
// path and slash commands use — that's the safety boundary. The loop body
// is in src/nativeToolLoop.mjs so it is reachable from tests; here we just
// pass in the handles it needs.
const hadFailureRef = { value: hadFailure };
try {
raw = await brain.complete(history, (t) => printw(t));
printw(RST + "\n");
if (config.useNativeTools) {
await runNativeToolLoop({
history,
completeWithTools: brain.completeWithTools,
getToolDefinitions,
handleAction,
dispatchToolCall,
isWrite,
printw,
println,
DIM,
RST,
c,
SCRIPTED,
hadFailure: hadFailureRef,
});
hadFailure = propagateHadFailure(hadFailureRef, hadFailure);
} else {
// v0 JSON protocol: the model's raw output (thinking + JSON) streams dimmed
// to the conversational surface; the executed result prints bright on stdout.
const raw = await brain.complete(history, (t) => printw(t));
printw(RST + "\n");
history.push({ role: "assistant", content: raw });

const action = parseAction(raw);
const result = await handleAction(action);
if (result == null) println(""); // model chose to just chat; its text already streamed
}
} catch (err) {
printw(RST);
println(c.red(` model error: ${err.message}`) + "\n");
if (SCRIPTED) hadFailure = true;
return true;
}
history.push({ role: "assistant", content: raw });

const action = parseAction(raw);
const handled = await handleAction(action);
if (!handled) println(""); // model chose to just chat; its text already streamed
return true;
}

const history = [{ role: "system", content: systemPrompt() }];
const history = buildInitialHistory();

if (SCRIPTED) {
// Scripted mode: no readline at all. Execute the buffered lines in order;
Expand Down Expand Up @@ -662,7 +720,16 @@ async function main() {
}
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
// Test seam: importing this module with NAD_CLI_NO_RUN=1 loads its exports
// (handleAction, buildInitialHistory, propagateHadFailure) without starting the
// REPL — which would otherwise drain stdin, load the model, and call
// process.exit. Production entry points (npm start, node dist/cli.mjs) never set
// it, so runtime behavior is unchanged.
if (!process.env.NAD_CLI_NO_RUN) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

export { handleAction };
Loading
Loading