src / core / result.ts
src / core / result.ts
import type { ArtifactRef } from "./artifacts";
import { errorSummary } from "./errors";
export const RESULT_PROTOCOL = "agentic-workspace/v1" as const;
export interface RetentionHint {
importance: "low" | "normal" | "high" | "critical";
facts?: string[];
omit_when_summarizing?: string[];
}
export interface ToolEnvelope<T = unknown> {
protocol: typeof RESULT_PROTOCOL;
ok: boolean;
operation: string;
summary: string;
data?: T;
artifacts?: ArtifactRef[];
retention: RetentionHint;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
export function okResult<T>(input: {
operation: string;
summary: string;
data?: T;
artifacts?: ArtifactRef[];
importance?: RetentionHint["importance"];
facts?: string[];
omit?: string[];
}): ToolEnvelope<T> {
return {
protocol: RESULT_PROTOCOL,
ok: true,
operation: input.operation,
summary: input.summary,
retention: {
importance: input.importance ?? "normal",
...(input.facts?.length ? { facts: input.facts } : {}),
...(input.omit?.length ? { omit_when_summarizing: input.omit } : {}),
},
...(input.artifacts?.length ? { artifacts: input.artifacts } : {}),
...(input.data === undefined ? {} : { data: input.data }),
};
}
export function errorResult(
operation: string,
error: unknown,
importance: RetentionHint["importance"] = "high",
): ToolEnvelope {
const normalized = errorSummary(error);
return {
protocol: RESULT_PROTOCOL,
ok: false,
operation,
summary: normalized.message,
retention: {
importance,
facts: [`${operation} failed: ${normalized.message}`],
},
error: normalized,
};
}
/**
* Bounds a list of small records to a character budget, keeping the leading
* entries in order and reporting how many were dropped. The `omittedEntries`
* counterpart to `boundedPreview` — `workspace_inspect list` and `overview` cap
* their entry arrays the same way.
*
* The budget is measured against the serialized JSON the model actually reads,
* so a caller that passes the plugin's `maxToolResultChars` setting knows the
* field cannot outgrow it. Iteration is ordered and the result depends on
* nothing but its arguments, so the field stays byte-stable across compactions.
*/
export function boundedEntries<T>(
entries: T[],
maxChars: number,
): { entries: T[]; omittedEntries: number } {
let used = 2; // The enclosing "[]".
for (let index = 0; index < entries.length; index++) {
used += JSON.stringify(entries[index]).length + (index === 0 ? 0 : 1);
if (used > maxChars) {
return {
entries: entries.slice(0, index),
omittedEntries: entries.length - index,
};
}
}
return { entries, omittedEntries: 0 };
}
export function boundedPreview(
content: string,
maxChars: number,
): { preview: string; truncated: boolean; omittedChars: number } {
if (maxChars <= 0) {
return {
preview: "",
truncated: content.length > 0,
omittedChars: content.length,
};
}
if (content.length <= maxChars) {
return { preview: content, truncated: false, omittedChars: 0 };
}
let marker = `\n[${content.length.toLocaleString()} characters omitted from this preview]\n`;
if (marker.length >= maxChars) {
return {
preview: marker.slice(0, maxChars),
truncated: true,
omittedChars: content.length,
};
}
let usable = maxChars - marker.length;
let head = Math.floor(usable * 0.6);
let tail = usable - head;
let omitted = content.length - head - tail;
marker = `\n[${omitted.toLocaleString()} characters omitted from this preview]\n`;
usable = Math.max(0, maxChars - marker.length);
head = Math.floor(usable * 0.6);
tail = usable - head;
omitted = Math.max(0, content.length - head - tail);
marker = `\n[${omitted.toLocaleString()} characters omitted from this preview]\n`;
const preview = `${content.slice(0, head)}${marker}${
tail > 0 ? content.slice(content.length - tail) : ""
}`.slice(0, maxChars);
return { preview, truncated: true, omittedChars: omitted };
}
import type { ArtifactRef } from "./artifacts";
import { errorSummary } from "./errors";
export const RESULT_PROTOCOL = "agentic-workspace/v1" as const;
export interface RetentionHint {
importance: "low" | "normal" | "high" | "critical";
facts?: string[];
omit_when_summarizing?: string[];
}
export interface ToolEnvelope<T = unknown> {
protocol: typeof RESULT_PROTOCOL;
ok: boolean;
operation: string;
summary: string;
data?: T;
artifacts?: ArtifactRef[];
retention: RetentionHint;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
export function okResult<T>(input: {
operation: string;
summary: string;
data?: T;
artifacts?: ArtifactRef[];
importance?: RetentionHint["importance"];
facts?: string[];
omit?: string[];
}): ToolEnvelope<T> {
return {
protocol: RESULT_PROTOCOL,
ok: true,
operation: input.operation,
summary: input.summary,
retention: {
importance: input.importance ?? "normal",
...(input.facts?.length ? { facts: input.facts } : {}),
...(input.omit?.length ? { omit_when_summarizing: input.omit } : {}),
},
...(input.artifacts?.length ? { artifacts: input.artifacts } : {}),
...(input.data === undefined ? {} : { data: input.data }),
};
}
export function errorResult(
operation: string,
error: unknown,
importance: RetentionHint["importance"] = "high",
): ToolEnvelope {
const normalized = errorSummary(error);
return {
protocol: RESULT_PROTOCOL,
ok: false,
operation,
summary: normalized.message,
retention: {
importance,
facts: [`${operation} failed: ${normalized.message}`],
},
error: normalized,
};
}
/**
* Bounds a list of small records to a character budget, keeping the leading
* entries in order and reporting how many were dropped. The `omittedEntries`
* counterpart to `boundedPreview` — `workspace_inspect list` and `overview` cap
* their entry arrays the same way.
*
* The budget is measured against the serialized JSON the model actually reads,
* so a caller that passes the plugin's `maxToolResultChars` setting knows the
* field cannot outgrow it. Iteration is ordered and the result depends on
* nothing but its arguments, so the field stays byte-stable across compactions.
*/
export function boundedEntries<T>(
entries: T[],
maxChars: number,
): { entries: T[]; omittedEntries: number } {
let used = 2; // The enclosing "[]".
for (let index = 0; index < entries.length; index++) {
used += JSON.stringify(entries[index]).length + (index === 0 ? 0 : 1);
if (used > maxChars) {
return {
entries: entries.slice(0, index),
omittedEntries: entries.length - index,
};
}
}
return { entries, omittedEntries: 0 };
}
export function boundedPreview(
content: string,
maxChars: number,
): { preview: string; truncated: boolean; omittedChars: number } {
if (maxChars <= 0) {
return {
preview: "",
truncated: content.length > 0,
omittedChars: content.length,
};
}
if (content.length <= maxChars) {
return { preview: content, truncated: false, omittedChars: 0 };
}
let marker = `\n[${content.length.toLocaleString()} characters omitted from this preview]\n`;
if (marker.length >= maxChars) {
return {
preview: marker.slice(0, maxChars),
truncated: true,
omittedChars: content.length,
};
}
let usable = maxChars - marker.length;
let head = Math.floor(usable * 0.6);
let tail = usable - head;
let omitted = content.length - head - tail;
marker = `\n[${omitted.toLocaleString()} characters omitted from this preview]\n`;
usable = Math.max(0, maxChars - marker.length);
head = Math.floor(usable * 0.6);
tail = usable - head;
omitted = Math.max(0, content.length - head - tail);
marker = `\n[${omitted.toLocaleString()} characters omitted from this preview]\n`;
const preview = `${content.slice(0, head)}${marker}${
tail > 0 ? content.slice(content.length - tail) : ""
}`.slice(0, maxChars);
return { preview, truncated: true, omittedChars: omitted };
}