feat(agent): add provider options passthrough, tool call records, and completion validation retries

This commit is contained in:
2026-05-07 10:26:45 +00:00
parent 0dde716109
commit b08cb3689e
10 changed files with 901 additions and 603 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 2026-05-07 - 3.1.0 - feat(agent)
add provider options passthrough, tool call records, and completion validation retries
- forward provider-specific options to the underlying streamText call
- return structured tool call records with inputs, outputs, and errors in agent results
- support validateCompletion with reprompting and configurable validation retry limits
- export ProviderOptions and tool call record types for consumers
- update tests and documentation for the new agent run options and result fields
## 2026-04-30 - 3.0.3 - fix(build)
tighten TypeScript configuration and update dependencies for zod v4 compatibility
+1 -1
View File
@@ -37,7 +37,7 @@
"@types/node": "^25.6.0"
},
"dependencies": {
"@push.rocks/smartai": "^2.0.0",
"@push.rocks/smartai": "^2.2.0",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartshell": "^3.3.8",
+537 -587
View File
File diff suppressed because it is too large Load Diff
+68 -2
View File
@@ -91,11 +91,15 @@ The single entry point. Options:
| `prompt` | `string` | *required* | The user's task/question |
| `system` | `string` | `undefined` | System prompt |
| `tools` | `ToolSet` | `{}` | Tools the agent can call |
| `providerOptions` | `ProviderOptions` | `undefined` | Provider-specific AI SDK request options passed through to `streamText()` |
| `maxSteps` | `number` | `20` | Max agentic steps before stopping |
| `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) |
| `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors |
| `onToken` | `(delta: string) => void` | — | Streaming token callback |
| `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked |
| `onToolResult` | `(name: string, result: unknown) => void` | — | Called when a tool finishes |
| `validateCompletion` | `(result) => string \| void` | — | Return a string to reject and reprompt an incomplete run |
| `maxValidationRetries` | `number` | `0` | Number of validation-triggered reprompts allowed |
| `onContextOverflow` | `(messages) => messages` | — | Handle context overflow (e.g., compact messages) |
### `IAgentRunResult`
@@ -107,13 +111,75 @@ interface IAgentRunResult {
steps: number; // Number of agentic steps taken
messages: ModelMessage[]; // Full conversation for multi-turn
usage: {
promptTokens: number;
completionTokens: number;
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
toolCalls: Array<{
toolName: string;
input: unknown;
output?: unknown;
error?: string;
}>;
}
```
### OpenAI Provider Options
Use `providerOptions` for provider-specific request settings such as GPT reasoning effort. SmartAgent forwards the object unchanged to AI SDK `streamText()`.
```typescript
import { getModelSetup } from '@push.rocks/smartai';
import { runAgent } from '@push.rocks/smartagent';
const setup = getModelSetup({
provider: 'openai',
model: 'gpt-5.5',
apiKey: process.env.OPENAI_API_KEY,
providerOptions: {
openai: {
reasoningEffort: 'xhigh',
},
},
});
const result = await runAgent({
model: setup.model,
system: 'You handle financial documents carefully.',
prompt: 'Process this inbox document.',
tools,
maxSteps: 20,
providerOptions: setup.providerOptions,
});
const saved = result.toolCalls.some((call) =>
call.toolName === 'saveVoucher' || call.toolName === 'saveBankStatement',
);
```
### Completion Validation
Use `validateCompletion` when a workflow must not finish unless a required side-effect happened. Return `void` to accept the run, or return a string to append that string as a new user message and continue. If retries are exhausted, `runAgent()` throws.
```typescript
const result = await runAgent({
model,
prompt: 'Process this inbox document.',
tools,
maxSteps: 20,
maxValidationRetries: 1,
validateCompletion: (result) => {
const saved = result.toolCalls.some((call) =>
call.toolName === 'saveVoucher' || call.toolName === 'saveBankStatement',
);
if (!saved) {
return 'You must call saveVoucher or saveBankStatement before finalizing.';
}
},
});
```
## Defining Tools 🛠️
Tools use Vercel AI SDK's `tool()` helper with Zod schemas:
+153
View File
@@ -1,8 +1,56 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
import * as smartagent from '../ts/index.js';
import { filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
import { compactMessages } from '../ts_compaction/index.js';
const createUsage = (inputTokens: number, outputTokens: number) => ({
inputTokens: {
total: inputTokens,
noCache: inputTokens,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: {
total: outputTokens,
text: outputTokens,
reasoning: 0,
},
});
const createTextStreamResult = (text: string) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: text },
{ type: 'text-end', id: 'text-1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: createUsage(1, 1),
},
] as any[]),
});
const createToolCallStreamResult = (toolName: string, input: unknown) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
{
type: 'tool-call',
toolCallId: 'tool-call-1',
toolName,
input: JSON.stringify(input),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool-calls' },
usage: createUsage(2, 1),
},
] as any[]),
});
// ============================================================
// Core exports
// ============================================================
@@ -35,6 +83,111 @@ tap.test('should re-export stepCountIs', async () => {
expect(smartagent.stepCountIs).toBeTypeOf('function');
});
tap.test('runAgent should forward providerOptions to streamText', async () => {
const model = new MockLanguageModelV3({
doStream: async () => createTextStreamResult('ok') as any,
});
const providerOptions = {
openai: {
reasoningEffort: 'xhigh',
},
} as const;
const result = await smartagent.runAgent({
model,
prompt: 'hello',
providerOptions,
});
expect(result.text).toEqual('ok');
expect((model.doStreamCalls[0].providerOptions as any).openai.reasoningEffort).toEqual('xhigh');
});
tap.test('runAgent should return final tool call records', async () => {
let streamCallCount = 0;
const callbackToolCalls: Array<{ name: string; input: unknown }> = [];
const callbackToolResults: Array<{ name: string; result: unknown }> = [];
const model = new MockLanguageModelV3({
doStream: async () => {
streamCallCount++;
return streamCallCount === 1
? createToolCallStreamResult('echo', { text: 'hello' }) as any
: createTextStreamResult('saved') as any;
},
});
const result = await smartagent.runAgent({
model,
prompt: 'echo hello',
tools: {
echo: smartagent.tool({
description: 'Echo text',
inputSchema: smartagent.z.object({ text: smartagent.z.string() }),
execute: async ({ text }: { text: string }) => `saved:${text}`,
}),
},
maxSteps: 5,
onToolCall: (name, input) => callbackToolCalls.push({ name, input }),
onToolResult: (name, result) => callbackToolResults.push({ name, result }),
});
const echoCall = result.toolCalls.find((toolCall) => toolCall.toolName === 'echo');
expect(result.text).toEqual('saved');
expect(echoCall).toBeTruthy();
expect(echoCall!.input).toEqual({ text: 'hello' });
expect(echoCall!.output).toEqual('saved:hello');
expect(callbackToolCalls[0]).toEqual({ name: 'echo', input: { text: 'hello' } });
expect(callbackToolResults[0]).toEqual({ name: 'echo', result: 'saved:hello' });
});
tap.test('runAgent should reprompt when validateCompletion returns a string', async () => {
let streamCallCount = 0;
let validationCallCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
streamCallCount++;
return createTextStreamResult(streamCallCount === 1 ? 'incomplete' : 'complete') as any;
},
});
const result = await smartagent.runAgent({
model,
prompt: 'process document',
maxValidationRetries: 1,
validateCompletion: (runResult) => {
validationCallCount++;
return runResult.text === 'complete' ? undefined : 'Call a save tool before finalizing.';
},
});
expect(result.text).toEqual('complete');
expect(validationCallCount).toEqual(2);
expect(model.doStreamCalls.length).toEqual(2);
expect(JSON.stringify(model.doStreamCalls[1].prompt)).toInclude('Call a save tool before finalizing.');
});
tap.test('runAgent should reject when validation retries are exhausted', async () => {
let threw = false;
const model = new MockLanguageModelV3({
doStream: async () => createTextStreamResult('incomplete') as any,
});
try {
await smartagent.runAgent({
model,
prompt: 'process document',
validateCompletion: () => 'Missing required save tool call.',
});
} catch (error) {
threw = true;
expect((error as Error).message).toInclude('Missing required save tool call.');
}
expect(threw).toBeTrue();
});
// ============================================================
// ToolRegistry
// ============================================================
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartagent',
version: '3.0.3',
version: '3.1.0',
description: 'Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.'
}
+1 -1
View File
@@ -3,7 +3,7 @@ export { ToolRegistry } from './smartagent.classes.toolregistry.js';
export { truncateOutput } from './smartagent.utils.truncation.js';
export type { ITruncateResult } from './smartagent.utils.truncation.js';
export { ContextOverflowError } from './smartagent.interfaces.js';
export type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
export type { IAgentRunOptions, IAgentRunResult, IAgentToolCallRecord, ProviderOptions } from './smartagent.interfaces.js';
// Re-export tool() and z so consumers can define tools without extra imports
export { tool, jsonSchema } from '@push.rocks/smartai';
+1 -1
View File
@@ -19,7 +19,7 @@ import { tool, jsonSchema } from '@push.rocks/smartai';
export { tool, jsonSchema };
export type { LanguageModelV3 } from '@push.rocks/smartai';
export type { LanguageModelV3, TSmartAiProviderOptions as ProviderOptions } from '@push.rocks/smartai';
// zod
import { z } from 'zod';
+109 -9
View File
@@ -1,7 +1,7 @@
// Retry backoff and context overflow logic derived from opencode (MIT) — https://github.com/sst/opencode
import * as plugins from './plugins.js';
import type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
import type { IAgentRunOptions, IAgentRunResult, IAgentToolCallRecord } from './smartagent.interfaces.js';
import { ContextOverflowError } from './smartagent.interfaces.js';
// Retry constants
@@ -76,11 +76,62 @@ function isContextOverflow(err: unknown): boolean {
return false;
}
function parseToolInput(input: unknown): unknown {
if (typeof input !== 'string') return input;
try {
return JSON.parse(input);
} catch {
return input;
}
}
function errorToString(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function recordToolCall(
toolCalls: IAgentToolCallRecord[],
toolCallIndexes: Map<string, number>,
toolCall: unknown,
update: { output?: unknown; error?: unknown } = {},
): void {
const call = toolCall as any;
const toolCallId = call?.toolCallId;
const nextRecord: IAgentToolCallRecord = {
toolName: String(call?.toolName ?? ''),
input: parseToolInput(call?.input ?? call?.args),
};
const hasOutput = Object.prototype.hasOwnProperty.call(update, 'output');
const hasError = Object.prototype.hasOwnProperty.call(update, 'error');
if (hasOutput) nextRecord.output = update.output;
if (hasError && update.error !== undefined) nextRecord.error = errorToString(update.error);
const existingIndex = typeof toolCallId === 'string' ? toolCallIndexes.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingRecord = toolCalls[existingIndex];
existingRecord.toolName = nextRecord.toolName || existingRecord.toolName;
if (nextRecord.input !== undefined) existingRecord.input = nextRecord.input;
if (hasOutput) existingRecord.output = nextRecord.output;
if (nextRecord.error !== undefined) existingRecord.error = nextRecord.error;
return;
}
toolCalls.push(nextRecord);
if (typeof toolCallId === 'string') {
toolCallIndexes.set(toolCallId, toolCalls.length - 1);
}
}
export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResult> {
let stepCount = 0;
let attempt = 0;
let totalInput = 0;
let totalOutput = 0;
let validationRetries = 0;
const toolCalls: IAgentToolCallRecord[] = [];
const toolCallIndexes = new Map<string, number>();
const tools = options.tools ?? {};
@@ -110,6 +161,7 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
system: options.system,
messages,
tools: allTools,
providerOptions: options.providerOptions,
stopWhen: plugins.stepCountIs(options.maxSteps ?? 20),
maxRetries: 0, // handled manually below
abortSignal: options.abort,
@@ -137,20 +189,48 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
experimental_onToolCallStart: options.onToolCall
? ({ toolCall }) => {
options.onToolCall!(toolCall.toolName, (toolCall as any).input ?? (toolCall as any).args);
const input = parseToolInput((toolCall as any).input ?? (toolCall as any).args);
recordToolCall(toolCalls, toolCallIndexes, toolCall);
options.onToolCall!(toolCall.toolName, input);
}
: undefined,
: ({ toolCall }) => {
recordToolCall(toolCalls, toolCallIndexes, toolCall);
},
experimental_onToolCallFinish: options.onToolResult
? ({ toolCall, output }) => {
options.onToolResult!(toolCall.toolName, output);
? (event) => {
recordToolCall(
toolCalls,
toolCallIndexes,
event.toolCall,
event.success ? { output: event.output } : { error: event.error },
);
options.onToolResult!(event.toolCall.toolName, event.success ? event.output : undefined);
}
: undefined,
: (event) => {
recordToolCall(
toolCalls,
toolCallIndexes,
event.toolCall,
event.success ? { output: event.output } : { error: event.error },
);
},
onStepFinish: ({ usage }) => {
onStepFinish: ({ usage, toolCalls: stepToolCalls, toolResults, content }) => {
stepCount++;
totalInput += usage?.inputTokens ?? 0;
totalOutput += usage?.outputTokens ?? 0;
for (const toolCall of stepToolCalls) {
recordToolCall(toolCalls, toolCallIndexes, toolCall);
}
for (const toolResult of toolResults) {
recordToolCall(toolCalls, toolCallIndexes, toolResult, { output: (toolResult as any).output });
}
for (const part of content) {
if ((part as any).type === 'tool-error') {
recordToolCall(toolCalls, toolCallIndexes, part, { error: (part as any).error });
}
}
},
});
@@ -158,12 +238,13 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
const text = await result.text;
const finishReason = await result.finishReason;
const responseData = await result.response;
const responseMessages = responseData.messages as plugins.ModelMessage[];
attempt = 0; // reset on success
return {
const runResult: IAgentRunResult = {
text,
messages: responseData.messages as plugins.ModelMessage[],
messages: responseMessages,
steps: stepCount,
finishReason,
usage: {
@@ -171,7 +252,26 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
outputTokens: totalOutput,
totalTokens: totalInput + totalOutput,
},
toolCalls,
};
if (options.validateCompletion) {
const validationPrompt = await options.validateCompletion(runResult);
if (typeof validationPrompt === 'string') {
if (validationRetries >= (options.maxValidationRetries ?? 0)) {
throw new Error(`Agent completion validation failed: ${validationPrompt}`);
}
validationRetries++;
messages = [
...messages,
...responseMessages,
{ role: 'user' as const, content: validationPrompt },
];
continue;
}
}
return runResult;
} catch (err: unknown) {
// Abort — don't retry
if (err instanceof DOMException && err.name === 'AbortError') throw err;
+21 -1
View File
@@ -1,4 +1,13 @@
import type { ToolSet, ModelMessage, LanguageModelV3 } from './plugins.js';
import type { ToolSet, ModelMessage, LanguageModelV3, ProviderOptions } from './plugins.js';
export type { ProviderOptions };
export interface IAgentToolCallRecord {
toolName: string;
input: unknown;
output?: unknown;
error?: string;
}
export interface IAgentRunOptions {
/** The LanguageModelV3 to use — from smartai.getModel() */
@@ -9,6 +18,8 @@ export interface IAgentRunOptions {
system?: string;
/** Tools available to the agent */
tools?: ToolSet;
/** Provider-specific AI SDK request options passed through to streamText() */
providerOptions?: ProviderOptions;
/**
* Maximum number of LLM↔tool round trips.
* Each step may execute multiple tools in parallel.
@@ -23,6 +34,13 @@ export interface IAgentRunOptions {
onToolCall?: (toolName: string, input: unknown) => void;
/** Called when a tool call completes */
onToolResult?: (toolName: string, result: unknown) => void;
/**
* Validate the completed run. Return a string to reject the run and reprompt,
* or return void to accept the result.
*/
validateCompletion?: (result: IAgentRunResult) => Promise<string | void> | string | void;
/** Number of validation-triggered reprompts allowed. Default: 0 */
maxValidationRetries?: number;
/**
* Called when total token usage approaches the model's context limit.
* Receives the full message history and must return a compacted replacement.
@@ -44,6 +62,8 @@ export interface IAgentRunResult {
finishReason: string;
/** Accumulated token usage across all steps */
usage: { inputTokens: number; outputTokens: number; totalTokens: number };
/** Tool calls observed during the run, including inputs and outputs/errors when available */
toolCalls: IAgentToolCallRecord[];
}
export class ContextOverflowError extends Error {