mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-04 15:26:55 +02:00
Both conflicts were adjacency rather than intent. Main rewrote only the Pi Credential Reuse bullet while Capella rewrote the Audit System bullet beside it, and the two branches added grok-mermaid and handlebars at the same alphabetical slot in the lockfile. The pi bump to 0.84.2 also widened StopReason with two states the Capella structured-generation port could not compile against. - keep main's Pi bullet and Capella's Audit bullet, whose prose matches the code - keep both lockfile entries; pnpm install --lockfile-only reproduces the result - classify the new pending and deferred stop reasons as a rejected request
152 lines
6.4 KiB
TypeScript
152 lines
6.4 KiB
TypeScript
// Copyright (C) 2026 Keygraph, Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License version 3
|
|
// as published by the Free Software Foundation.
|
|
|
|
import type { AssistantMessage, Context, ToolCall } from '@earendil-works/pi-ai';
|
|
import { Value } from 'typebox/value';
|
|
import { providerFailureSentence } from '../../services/error-handling.js';
|
|
import { type ModelHost, modelHost } from '../model-host.js';
|
|
import type {
|
|
StructuredGenerationPort,
|
|
StructuredGenerationRequest,
|
|
StructuredGenerationResult,
|
|
} from '../structured-generation.js';
|
|
import { type CapturedSubmitTool, createGenericSubmitTool } from '../submit-tool.js';
|
|
|
|
const ZERO_USAGE = { inputTokens: 0, outputTokens: 0, costUsd: 0 } as const;
|
|
|
|
// True only when this caller's own signal aborted and the error traces back to it. Walk a bounded,
|
|
// cycle-guarded cause chain so a cancellation wrapped several layers deep is still recognized as a
|
|
// cancellation and not misreported as a provider error. Without the `signal.aborted` gate an
|
|
// unrelated AbortError from the provider could be mistaken for our cancellation.
|
|
function isSignalCancellation(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
if (signal?.aborted !== true) return false;
|
|
|
|
let current: unknown = error;
|
|
const seen = new Set<unknown>();
|
|
for (let depth = 0; depth < 8 && current !== undefined && current !== null && !seen.has(current); depth++) {
|
|
if (current === signal.reason) return true;
|
|
seen.add(current);
|
|
const errorName = current instanceof Error ? current.name : undefined;
|
|
if (errorName === 'AbortError' || errorName === 'CancelledFailure') return true;
|
|
current = current instanceof Error ? current.cause : undefined;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function responseUsage(response: AssistantMessage): StructuredGenerationResult['usage'] {
|
|
return {
|
|
inputTokens: response.usage.input,
|
|
outputTokens: response.usage.output,
|
|
costUsd: response.usage.cost.total,
|
|
};
|
|
}
|
|
|
|
type SubmitExecutor = (toolCallId: string, parameters: Record<string, unknown>) => Promise<unknown>;
|
|
|
|
async function captureSingleValidSubmission(
|
|
toolCalls: readonly ToolCall[],
|
|
submitTool: CapturedSubmitTool,
|
|
): Promise<Array<{ name: string; arguments: unknown }>> {
|
|
const returnedCalls = toolCalls.map((call) => ({ name: call.name, arguments: call.arguments }));
|
|
const call = toolCalls.length === 1 ? toolCalls[0] : undefined;
|
|
if (call?.name !== submitTool.tool.name) return returnedCalls;
|
|
|
|
if (!Value.Check(submitTool.tool.parameters, call.arguments)) return returnedCalls;
|
|
|
|
// completeSimple returns tool calls but does not execute them. Invoke the captured
|
|
// definition only after its TypeBox validator accepts the sole submission.
|
|
const execute = submitTool.tool.execute as unknown as SubmitExecutor;
|
|
await execute(call.id, call.arguments);
|
|
const captured = submitTool.getCaptured();
|
|
return [{ name: call.name, arguments: captured }];
|
|
}
|
|
|
|
async function generate(host: ModelHost, request: StructuredGenerationRequest): Promise<StructuredGenerationResult> {
|
|
const submitTool = createGenericSubmitTool(request.tool.parametersJsonSchema);
|
|
const context: Context = {
|
|
...(request.systemPrompt !== undefined && { systemPrompt: request.systemPrompt }),
|
|
messages: [{ role: 'user', content: request.userContent, timestamp: Date.now() }],
|
|
tools: [
|
|
{
|
|
name: submitTool.tool.name,
|
|
description: request.tool.description,
|
|
parameters: submitTool.tool.parameters,
|
|
},
|
|
],
|
|
};
|
|
|
|
let response: AssistantMessage;
|
|
try {
|
|
const selection = await host.resolve('small');
|
|
// One enrichment batch is one billable provider request. Temporal owns any
|
|
// retry after this boundary, so provider-level retries stay disabled here.
|
|
response = await selection.modelRuntime.completeSimple(selection.model, context, {
|
|
maxTokens: request.maxTokens,
|
|
maxRetries: 0,
|
|
...(request.signal !== undefined && { signal: request.signal }),
|
|
});
|
|
} catch (error) {
|
|
if (isSignalCancellation(error, request.signal)) {
|
|
return { stopReason: 'aborted', toolCalls: [], usage: ZERO_USAGE };
|
|
}
|
|
const failure = host.classify(error);
|
|
return {
|
|
stopReason: 'error',
|
|
toolCalls: [],
|
|
usage: ZERO_USAGE,
|
|
errorMessage: providerFailureSentence(failure),
|
|
providerFailure: { type: failure.type, retryable: failure.retryable },
|
|
};
|
|
}
|
|
|
|
// `pending` and `deferred` are non-final provider states: completeSimple resolves only on a
|
|
// finished response and Shannon never requests a deferred one, so neither can carry a usable
|
|
// submission. Classify them the way a rejected request is classified, so the caller retries
|
|
// instead of reading an empty response as a successful generation.
|
|
if (response.stopReason === 'error' || response.stopReason === 'pending' || response.stopReason === 'deferred') {
|
|
const failure = host.classify(response);
|
|
return {
|
|
stopReason: 'error',
|
|
toolCalls: [],
|
|
usage: responseUsage(response),
|
|
errorMessage: providerFailureSentence(failure),
|
|
providerFailure: { type: failure.type, retryable: failure.retryable },
|
|
};
|
|
}
|
|
if (response.stopReason === 'aborted') {
|
|
// An abort with our signal set is a real cancellation. An abort without it is a provider-side
|
|
// stop we did not ask for, so classify it as an error the caller can retry on.
|
|
if (request.signal?.aborted === true) {
|
|
return { stopReason: 'aborted', toolCalls: [], usage: responseUsage(response) };
|
|
}
|
|
const failure = host.classify(response);
|
|
return {
|
|
stopReason: 'error',
|
|
toolCalls: [],
|
|
usage: responseUsage(response),
|
|
errorMessage: providerFailureSentence(failure),
|
|
providerFailure: { type: failure.type, retryable: failure.retryable },
|
|
};
|
|
}
|
|
|
|
const toolCalls = response.content.filter((block): block is ToolCall => block.type === 'toolCall');
|
|
const capturedCalls = await captureSingleValidSubmission(toolCalls, submitTool);
|
|
return {
|
|
stopReason: response.stopReason,
|
|
toolCalls: capturedCalls,
|
|
usage: responseUsage(response),
|
|
};
|
|
}
|
|
|
|
/** Build the one-request Pi adapter used by SAST enrichment. */
|
|
export function createPiStructuredGenerationPort(host: ModelHost = modelHost): StructuredGenerationPort<void> {
|
|
return {
|
|
generate(request: StructuredGenerationRequest): Promise<StructuredGenerationResult> {
|
|
return generate(host, request);
|
|
},
|
|
};
|
|
}
|