Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
104 changes: 104 additions & 0 deletions docs/architecture/tool-invocation-contract-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Tool Invocation Contract v1

## Scope

This contract standardizes tool behavior across three boundaries:

1. Planner intent (`ToolInvocationIntent`)
2. Orchestrator eligibility decision (`ToolInvocationRequest`)
3. Runtime outcome (`ToolExecutionContext`)

Contract source of truth:

- `packages/contracts/src/ethics-core/types.ts`

Backend remains authoritative for provenance, trace, and cost semantics.

## Canonical Outcome States

`ToolExecutionContext.status` uses:

- `executed`
- `skipped`
- `failed`

When `status` is `skipped` or `failed`, `reasonCode` must be present.

Tool-oriented `reasonCode` values:

- `tool_not_requested`
- `tool_not_used`
- `tool_unavailable`
- `tool_execution_error`
- `search_not_supported_by_selected_profile`
- `unspecified_tool_outcome`

## Mapping Rules

Planner to orchestrator:

1. If `generation.search` is absent, emit `ToolInvocationIntent` with `requested=false`.
2. If `generation.search` is present, emit `ToolInvocationIntent` with:
`toolName="web_search"`, `requested=true`, and serializable input (`query`, `intent`, `contextSize`, optional `repoHints`).

Orchestrator to runtime:

1. Start with `ToolInvocationRequest`:
`requested=true` + `eligible=true` when planner requested search.
2. If selected profile cannot search and reroute is not allowed/available:
set `eligible=false` + `reasonCode="search_not_supported_by_selected_profile"` and do not send `search` to runtime.
3. If provider lacks mapped search tool support at runtime adapter:
set outcome to `skipped` + `reasonCode="tool_unavailable"` (fail-open generation continues).

Runtime to metadata:

1. Runtime may emit `GenerationResult.toolExecution`.
2. Backend may override with stricter orchestrator policy outcomes.
3. Final trace metadata records tool outcome in `ResponseMetadata.execution[]` (`kind="tool"`).

## Example Metadata Payloads

Success path (`executed`):

```json
{
"execution": [
{
"kind": "tool",
"status": "executed",
"toolName": "web_search",
"durationMs": 42
}
]
}
```

Fail-open path (`skipped` due eligibility):

```json
{
"execution": [
{
"kind": "tool",
"status": "skipped",
"toolName": "web_search",
"reasonCode": "search_not_supported_by_selected_profile"
}
]
}
```

Fail-open path (`skipped` due runtime adapter support gap):

```json
{
"execution": [
{
"kind": "tool",
"status": "skipped",
"toolName": "web_search",
"reasonCode": "tool_unavailable"
}
]
}
```
7 changes: 7 additions & 0 deletions packages/agent-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
SupportedOpenAITextModel,
SupportedProvider,
} from '@footnote/contracts';
import type { ToolExecutionContext } from '@footnote/contracts/ethics-core';
import type {
InternalTtsCosts,
InternalTtsOptions,
Expand Down Expand Up @@ -239,6 +240,12 @@ export interface GenerationResult {
* Runtime-reported provenance classification, when available.
*/
provenance?: GenerationProvenance;
/**
* Optional canonical tool execution outcome reported by the runtime.
* Backend orchestration can still override this when policy requires
* deterministic fail-open semantics.
*/
toolExecution?: ToolExecutionContext;
/**
* Placeholder memory retrieval payload reserved for future memory features.
* Current flows should leave this undefined.
Expand Down
36 changes: 34 additions & 2 deletions packages/agent-runtime/src/voltagentRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
GenerationUsage,
RuntimeMessage,
} from './index.js';
import type { ToolExecutionContext } from '@footnote/contracts/ethics-core';

type VoltAgentOpenAiProviderOptions = {
reasoningEffort?: 'low' | 'medium' | 'high';
Expand Down Expand Up @@ -702,7 +703,8 @@ const extractCitationsFromSources = (
const normalizeVoltAgentResult = (
executedModel: string,
request: GenerationRequest,
result: VoltAgentTextResult
result: VoltAgentTextResult,
fallbackToolExecution?: ToolExecutionContext
): GenerationResult => {
const responseModel = result.response?.modelId ?? executedModel;
const usage: GenerationUsage | undefined = result.usage
Expand All @@ -725,6 +727,18 @@ const normalizeVoltAgentResult = (
: citationsFromSources;
const retrievalUsed =
hasSearchRequest && (hasWebSearchCall || citations.length > 0);
const inferredToolExecution: ToolExecutionContext | undefined =
hasSearchRequest
? {
toolName: 'web_search',
status: retrievalUsed ? 'executed' : 'skipped',
...(retrievalUsed
? {}
: {
reasonCode: 'tool_not_used',
}),
}
: undefined;

return {
text: result.text,
Expand All @@ -737,6 +751,11 @@ const normalizeVoltAgentResult = (
used: retrievalUsed,
},
provenance: retrievalUsed ? 'Retrieved' : 'Inferred',
...(fallbackToolExecution !== undefined
? { toolExecution: fallbackToolExecution }
: inferredToolExecution !== undefined
? { toolExecution: inferredToolExecution }
: {}),
};
};

Expand Down Expand Up @@ -924,6 +943,18 @@ const createVoltAgentRuntime = ({
canUseSearch &&
request.search !== undefined &&
canProviderUseSearchTools;
const fallbackToolExecution: ToolExecutionContext | undefined =
request.search === undefined
? undefined
: shouldForwardSearch
? undefined
: {
toolName: 'web_search',
status: 'skipped',
reasonCode: canProviderUseSearchTools
? 'search_not_supported_by_selected_profile'
: 'tool_unavailable',
};
const requestForResult: GenerationRequest =
shouldForwardSearch || request.search === undefined
? request
Expand Down Expand Up @@ -971,7 +1002,8 @@ const createVoltAgentRuntime = ({
return normalizeVoltAgentResult(
executedModel,
requestForResult,
result
result,
fallbackToolExecution
);
},
};
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-runtime/test/voltagentRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ test('voltagent runtime executes search requests through the VoltAgent executor'
requested: true,
used: true,
});
assert.deepEqual(result.toolExecution, {
toolName: 'web_search',
status: 'executed',
});
assert.equal(result.provenance, 'Retrieved');
});

Expand Down Expand Up @@ -279,7 +283,7 @@ test('voltagent runtime does not forward search for providers without a mapped s
}),
});

await runtime.generate({
const result = await runtime.generate({
messages: [{ role: 'user', content: 'Summarize this.' }],
model: 'ollama/llama3.2:3b',
search: {
Expand All @@ -293,6 +297,11 @@ test('voltagent runtime does not forward search for providers without a mapped s
});

assert.equal(seenOptions?.search, undefined);
assert.deepEqual(result.toolExecution, {
toolName: 'web_search',
status: 'skipped',
reasonCode: 'tool_unavailable',
});
});

test('voltagent runtime maps remote ollama provider to ollama-cloud and normalizes cloud base URL', async () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/api-client/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
PostChatRequest,
PostChatResponse,
} from '@footnote/contracts/web';
import type { ToolExecutionContext } from '@footnote/contracts/ethics-core';
import {
GetChatProfilesResponseSchema,
PostChatResponseSchema,
Expand All @@ -30,6 +31,8 @@ export type DiscordChatApiResponse =
| PostChatResponse
| UnknownChatActionResponse;

export type ChatToolExecutionContext = ToolExecutionContext;

export type ChatApi = {
getChatProfiles: (options?: {
signal?: AbortSignal;
Expand Down
2 changes: 2 additions & 0 deletions packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
createChatApi,
type CreateChatApiOptions,
type ChatApi,
type ChatToolExecutionContext,
type DiscordChatApiResponse,
type UnknownChatActionResponse,
} from './chat.js';
Expand Down Expand Up @@ -160,6 +161,7 @@ export type {
CreateInternalVoiceApiOptions,
CreateTraceApiOptions,
DiscordChatApiResponse,
ChatToolExecutionContext,
IncidentApi,
InternalImageApi,
InternalTextApi,
Expand Down
64 changes: 55 additions & 9 deletions packages/backend/src/services/chatOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import type {
ChatConversationMessage,
} from '@footnote/contracts/web';
import type {
ExecutionReasonCode,
ExecutionStatus,
ToolExecutionContext,
ToolInvocationIntent,
ToolInvocationRequest,
} from '@footnote/contracts/ethics-core';
import { renderConversationPromptLayers } from './prompts/conversationPromptLayers.js';
import {
Expand Down Expand Up @@ -55,6 +56,34 @@ const buildPlannerPayload = (
...(surfacePolicy && { surfacePolicy }),
});

/**
* Converts planner generation.search into a serializable tool-intent contract.
*/
const buildWebSearchToolIntent = (
generation: ChatGenerationPlan
): ToolInvocationIntent => {
if (!generation.search) {
return {
toolName: 'web_search',
requested: false,
};
}

return {
toolName: 'web_search',
requested: true,
input: {
query: generation.search.query,
intent: generation.search.intent,
contextSize: generation.search.contextSize,
...(generation.search.repoHints &&
generation.search.repoHints.length > 0 && {
repoHints: generation.search.repoHints,
}),
},
};
};

/**
* The orchestrator keeps surface-specific policy in one place while reusing the
* shared message-generation service for any branch that ends in text output.
Expand Down Expand Up @@ -259,13 +288,21 @@ export const createChatOrchestrator = ({
? { verbosity: requestGeneration.verbosity }
: {}),
};
let toolExecutionContext:
| {
toolName: 'web_search';
status: ExecutionStatus;
reasonCode?: ExecutionReasonCode;
}
| undefined;
const toolIntent = buildWebSearchToolIntent(generationForExecution);
let toolRequestContext: ToolInvocationRequest | undefined =
toolIntent.requested
? {
toolName: 'web_search',
requested: true,
eligible: true,
}
: {
toolName: 'web_search',
requested: false,
eligible: false,
reasonCode: 'tool_not_requested',
};
let toolExecutionContext: ToolExecutionContext | undefined;
if (
generationForExecution.search &&
!selectedResponseProfile.capabilities.canUseSearch
Expand Down Expand Up @@ -295,6 +332,12 @@ export const createChatOrchestrator = ({
...generationForExecution,
search: undefined,
};
toolRequestContext = {
toolName: 'web_search',
requested: true,
eligible: false,
reasonCode: 'search_not_supported_by_selected_profile',
};
toolExecutionContext = {
toolName: 'web_search',
status: 'skipped',
Expand Down Expand Up @@ -428,6 +471,8 @@ export const createChatOrchestrator = ({
profileId: executionPlan.profileId,
riskTier: executionPlan.riskTier,
generation: executionPlan.generation,
toolIntent,
toolRequest: toolRequestContext,
...(surfacePolicy && { surfacePolicy }),
},
}),
Expand Down Expand Up @@ -487,6 +532,7 @@ export const createChatOrchestrator = ({
effectiveProfileId: effectiveSelectedProfileId,
searchRequested: generationForExecution.search !== undefined,
toolStatus: toolExecutionContext?.status,
toolEligible: toolRequestContext?.eligible,
rerouteApplied,
fallbackApplied: plannerExecution.status === 'failed',
});
Expand Down
Loading
Loading