Skip to content
Closed
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
3 changes: 3 additions & 0 deletions packages/runtime/src/client-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ export type {
TaskOptions,
ThinkingLevel,
ToolDef,
ToolExecuteResult,
ToolParameters,
ToolResult,
ToolResultContent,
} from './types.ts';
3 changes: 3 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export type {
AgentConfig,
ModelConfig,
ToolDef,
ToolExecuteResult,
ToolParameters,
ToolResult,
ToolResultContent,
ThinkingLevel,
ProviderSettings,
} from './types.ts';
Expand Down
34 changes: 29 additions & 5 deletions packages/runtime/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,38 @@ import type {
TaskOptions,
ThinkingLevel,
ToolDef,
ToolExecuteResult,
ToolResult,
} from './types.ts';
import { addUsage, emptyUsage, fromProviderUsage } from './usage.ts';

const MAX_TASK_DEPTH = 4;

function isToolResult(result: ToolExecuteResult): result is ToolResult {
return typeof result === 'object' && result !== null && Array.isArray(result.content);
}

export function toAgentToolResult(
toolName: string,
result: ToolExecuteResult,
): AgentToolResult<any> {
if (typeof result === 'string') {
return {
content: [{ type: 'text' as const, text: result }],
details: { customTool: toolName },
};
}

if (!isToolResult(result)) {
throw new Error(
`[flue] Custom tool "${toolName}" returned an invalid result. ` +
'Return a string or an object with a content array.',
);
}

return result;
}

export interface CreateTaskSessionOptions {
parentSession: string;
taskId: string;
Expand Down Expand Up @@ -852,11 +879,8 @@ export class Session implements FlueSession {
parameters: toolDef.parameters as any,
async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
if (signal?.aborted) throw new Error('Operation aborted');
const resultText = await toolDef.execute(params as Record<string, any>, signal);
return {
content: [{ type: 'text' as const, text: resultText }],
details: { customTool: toolDef.name },
};
const result = await toolDef.execute(params as Record<string, any>, signal);
return toAgentToolResult(toolDef.name, result);
},
}),
);
Expand Down
13 changes: 10 additions & 3 deletions packages/runtime/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { AgentMessage, ThinkingLevel } from '@earendil-works/pi-agent-core';
import type {
AgentMessage,
AgentToolResult,
ThinkingLevel,
} from '@earendil-works/pi-agent-core';
import type { ImageContent, Model, TSchema } from '@earendil-works/pi-ai';
import type * as v from 'valibot';

Expand Down Expand Up @@ -47,6 +51,9 @@ export interface Role {
// ─── Custom Tools ───────────────────────────────────────────────────────────

export type ToolParameters = TSchema | Record<string, unknown>;
export type ToolResultContent = AgentToolResult<any>['content'][number];
export type ToolResult<TDetails = any> = AgentToolResult<TDetails>;
export type ToolExecuteResult<TDetails = any> = string | ToolResult<TDetails>;

/**
* Custom tool passed to init(), prompt(), skill(), or task(). init() tools are
Expand All @@ -61,8 +68,8 @@ export interface ToolDef<TParams extends ToolParameters = ToolParameters> {
description: string;
/** JSON Schema-compatible parameter schema. */
parameters: TParams;
/** Returns a string result sent back to the LLM. Thrown errors become tool errors. */
execute: (args: Record<string, any>, signal?: AbortSignal) => Promise<string>;
/** Returns a result sent back to the LLM. Thrown errors become tool errors. */
execute: (args: Record<string, any>, signal?: AbortSignal) => Promise<ToolExecuteResult>;
}

// ─── File Stat ──────────────────────────────────────────────────────────────
Expand Down
37 changes: 37 additions & 0 deletions packages/runtime/test/custom-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { toAgentToolResult } from '../src/session.ts';

describe('custom tool results', () => {
it('wraps string results as text content', () => {
expect(toAgentToolResult('lookup', 'hello')).toEqual({
content: [{ type: 'text', text: 'hello' }],
details: { customTool: 'lookup' },
});
});

it('preserves structured content and details', () => {
const result = toAgentToolResult('screenshot', {
content: [
{ type: 'text', text: 'screenshot captured' },
{ type: 'image', data: 'abc123', mimeType: 'image/png' },
],
details: { nodeId: 'page:page' },
});

expect(result).toEqual({
content: [
{ type: 'text', text: 'screenshot captured' },
{ type: 'image', data: 'abc123', mimeType: 'image/png' },
],
details: { nodeId: 'page:page' },
});
});

it('rejects invalid structured results', () => {
expect(() =>
toAgentToolResult('metadata', {
details: { nodeId: 'page:page' },
} as never),
).toThrow('Custom tool "metadata" returned an invalid result');
});
});
Loading