Pinned pi SDK 0.85.1 bridge, Python supervisor, artifact exporter, scripted backend peer, image checks and boundary checks under agent/**. Review findings F1-F3 are recorded in docs/implementation/PI_AGENT_REVIEW.md.
102 lines
6.3 KiB
TypeScript
102 lines
6.3 KiB
TypeScript
import { jsonBytes } from './json.js';
|
|
import { LIMITS } from './types.js';
|
|
import { isValidId } from './framing.js';
|
|
export function object(x: unknown): x is Record<string, any> {
|
|
return !!x && typeof x === 'object' && !Array.isArray(x);
|
|
}
|
|
export function keys(x: unknown, required: string[], optional: string[] = []): asserts x is Record<string, any> {
|
|
if (!object(x) || required.some(k => !Object.hasOwn(x, k)) || Object.keys(x).some(k => !required.includes(k) && !optional.includes(k))) throw new Error('Invalid fields');
|
|
}
|
|
export function integer(x: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): void {
|
|
if (!Number.isSafeInteger(x) || (x as number) < min || (x as number) > max) throw new Error('Invalid integer');
|
|
}
|
|
export function text(x: unknown, max: number, nonempty = false): void {
|
|
if (typeof x !== 'string' || Buffer.byteLength(x) > max || (nonempty && !x.trim())) throw new Error('Invalid text');
|
|
}
|
|
export function warning(x: unknown): void {
|
|
keys(x, ['code', 'message'], ['tool_call_id', 'name']);
|
|
text(x.code, 128, true); text(x.message, 1024);
|
|
if (x.tool_call_id !== undefined && !isValidId(x.tool_call_id)) throw new Error('Invalid warning ID');
|
|
if (x.name !== undefined) text(x.name, 1024);
|
|
}
|
|
export function envelope(x: any): void {
|
|
keys(x, ['v', 'type', 'id', 'payload'], ['reply_to']);
|
|
if (x.v !== 1 || !isValidId(x.id) || !x.id.startsWith('b_')) throw new Error('Invalid envelope');
|
|
if (x.type === 'start') {
|
|
if (Object.hasOwn(x, 'reply_to')) throw new Error('Unexpected correlation');
|
|
keys(x.payload, ['prompt', 'system_instruction', 'remaining_ms', 'model']);
|
|
text(x.payload.prompt, LIMITS.USER_PROMPT_MAX_BYTES, true);
|
|
text(x.payload.system_instruction, LIMITS.ENVELOPE_ALLOWANCE_BYTES, true);
|
|
integer(x.payload.remaining_ms, 1, LIMITS.MAX_DEADLINE_MS);
|
|
const m = x.payload.model;
|
|
keys(m, ['id', 'context_window_tokens', 'max_output_tokens']);
|
|
text(m.id, 1024, true); integer(m.context_window_tokens, 1); integer(m.max_output_tokens, 1, m.context_window_tokens);
|
|
const metadata = { ...x, payload: { ...x.payload, prompt: '' } };
|
|
if (jsonBytes(metadata, LIMITS.ENVELOPE_ALLOWANCE_BYTES) > LIMITS.ENVELOPE_ALLOWANCE_BYTES) throw new Error('Start metadata too large');
|
|
} else {
|
|
if (!isValidId(x.reply_to) || !x.reply_to.startsWith('a_')) throw new Error('Invalid correlation');
|
|
if (x.type === 'collection_ready') keys(x.payload, []);
|
|
else if (x.type === 'artifact_ack') {
|
|
keys(x.payload, ['transfer_id', 'decision', 'warning']);
|
|
if (!isValidId(x.payload.transfer_id) || !['accept', 'skip', 'stored'].includes(x.payload.decision)) throw new Error('Invalid acknowledgement');
|
|
if (x.payload.warning !== null) warning(x.payload.warning);
|
|
if (x.payload.decision === 'stored' && x.payload.warning !== null) throw new Error('Unexpected stored warning');
|
|
} else if (x.type === 'tool_response' || x.type === 'model_response') {
|
|
keys(x.payload, ['result', 'error']);
|
|
if (x.payload.error !== null) {
|
|
keys(x.payload.error, ['code', 'message']); text(x.payload.error.code, 128, true); text(x.payload.error.message, 1024);
|
|
if (x.payload.result !== null) throw new Error('Invalid error result');
|
|
} else {
|
|
if (!object(x.payload.result)) throw new Error('Invalid success result');
|
|
if (x.type === 'model_response') modelResponse(x.payload.result);
|
|
}
|
|
if (jsonBytes(x.payload, LIMITS.MODEL_RESULT_PAYLOAD_MAX_BYTES) > LIMITS.MODEL_RESULT_PAYLOAD_MAX_BYTES) throw new Error('Payload too large');
|
|
} else throw new Error('Unknown frame');
|
|
}
|
|
}
|
|
export function modelResponse(x: any): void {
|
|
keys(x, ['content', 'stop_reason', 'usage'], ['provider_state']);
|
|
if (!Array.isArray(x.content) || !['stop', 'tool_calls', 'length'].includes(x.stop_reason)) throw new Error('Invalid model result');
|
|
const ids = new Set<string>();
|
|
for (const c of x.content) {
|
|
if (c.type === 'text') { keys(c, ['type', 'text']); text(c.text, LIMITS.FINAL_MARKDOWN_MAX_BYTES); }
|
|
else if (c.type === 'tool_call') {
|
|
keys(c, ['type', 'id', 'name', 'arguments']);
|
|
if (!isValidId(c.id) || ids.has(c.id) || !object(c.arguments)) throw new Error('Invalid model call');
|
|
ids.add(c.id); text(c.name, 128, true);
|
|
if (jsonBytes(c.arguments, LIMITS.TOOL_REQUEST_PAYLOAD_MAX_BYTES) > LIMITS.TOOL_REQUEST_PAYLOAD_MAX_BYTES) throw new Error('Tool arguments too large');
|
|
} else throw new Error('Invalid content');
|
|
}
|
|
if ((ids.size > 0) !== (x.stop_reason === 'tool_calls') && x.stop_reason !== 'length') throw new Error('Invalid stop reason');
|
|
keys(x.usage, ['input_tokens', 'output_tokens']); integer(x.usage.input_tokens); integer(x.usage.output_tokens);
|
|
if (x.provider_state !== undefined) text(x.provider_state, 64 * 1024);
|
|
}
|
|
|
|
export function confluenceResult(x: any, tool: string): void {
|
|
const page = (p: any, extra: string[]) => {
|
|
keys(p, ['page_id', 'title', 'space', 'url', ...extra]);
|
|
text(p.page_id, 128, true);
|
|
if (!/^[0-9]+$/.test(p.page_id)) throw new Error('Invalid page ID');
|
|
for (const field of ['title', 'space', 'url']) text(p[field], LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES);
|
|
};
|
|
const pagination = (p: any, count: number) => {
|
|
keys(p, ['offset', 'limit', 'has_more']);
|
|
integer(p.offset, 0, 10000); integer(p.limit, 1, 50);
|
|
if (typeof p.has_more !== 'boolean' || count > p.limit || (!count && p.has_more)) throw new Error('Invalid pagination');
|
|
};
|
|
if (tool === 'confluence_view') {
|
|
page(x, ['markdown', 'truncated']); text(x.markdown, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES);
|
|
if (typeof x.truncated !== 'boolean') throw new Error('Invalid truncation');
|
|
} else if (tool === 'confluence_search') {
|
|
keys(x, ['pages', 'pagination']);
|
|
if (!Array.isArray(x.pages) || x.pages.length > 50) throw new Error('Invalid pages');
|
|
for (const p of x.pages) { page(p, ['snippet']); text(p.snippet, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES); }
|
|
pagination(x.pagination, x.pages.length);
|
|
} else if (tool === 'confluence_list_spaces') {
|
|
keys(x, ['spaces', 'pagination']);
|
|
if (!Array.isArray(x.spaces) || x.spaces.length > 50) throw new Error('Invalid spaces');
|
|
for (const s of x.spaces) { keys(s, ['key', 'name']); text(s.key, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES); text(s.name, LIMITS.TOOL_RESULT_PAYLOAD_MAX_BYTES); }
|
|
pagination(x.pagination, x.spaces.length);
|
|
} else throw new Error('Invalid Confluence operation');
|
|
}
|