src / agenticProtocol.ts
src / agenticProtocol.ts
/**
* Native compaction for Agentic Workspace's durable result envelope.
*
* Generic head/tail truncation can discard transaction IDs, run IDs, task
* state, source ledgers, and artifact paths. This module understands the
* protocol and reduces bulky payloads while preserving those recovery keys.
*/
import { CanonMessage } from "./view";
export const AGENTIC_WORKSPACE_PROTOCOL = "agentic-workspace/v1";
interface AgenticEnvelope {
protocol: typeof AGENTIC_WORKSPACE_PROTOCOL;
ok: boolean;
operation: string;
summary: string;
retention?: {
importance?: string;
facts?: unknown[];
omit_when_summarizing?: unknown[];
};
artifacts?: unknown[];
data?: unknown;
error?: unknown;
[key: string]: unknown;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function parseEnvelope(value: unknown): {
envelope: AgenticEnvelope;
inputWasString: boolean;
} | undefined {
let parsed = value;
const inputWasString = typeof value === "string";
if (inputWasString) {
const text = value.trim();
if (!text.startsWith("{") || !text.includes(AGENTIC_WORKSPACE_PROTOCOL)) {
return undefined;
}
try {
parsed = JSON.parse(text);
} catch {
return undefined;
}
}
if (
!isRecord(parsed) ||
parsed.protocol !== AGENTIC_WORKSPACE_PROTOCOL ||
typeof parsed.operation !== "string" ||
typeof parsed.summary !== "string"
) {
return undefined;
}
return { envelope: parsed as AgenticEnvelope, inputWasString };
}
function cloneJson<T>(value: T): T {
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
function deleteJsonPath(root: unknown, rawPath: string): void {
const parts = rawPath
.replace(/^\$\.?/, "")
.split(".")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0 || !isRecord(root)) return;
let current: Record<string, unknown> = root;
for (let index = 0; index < parts.length - 1; index++) {
const next = current[parts[index]];
if (!isRecord(next)) return;
current = next;
}
delete current[parts[parts.length - 1]];
}
const IMPORTANT_KEYS = new Set([
"id",
"status",
"state",
"mode",
"ok",
"operation",
"summary",
"title",
"objective",
"error",
"code",
"message",
"confidence",
"path",
"url",
"sha256",
"bytes",
"kind",
"description",
"text",
"note",
"priority",
"details",
"dependsOn",
"blockers",
"decisions",
"next",
"query",
"provider",
"excerpt",
"destructive",
"requiresReview",
"added",
"removed",
"beforeSha256",
"afterSha256",
"executable",
"args",
"cwd",
"exitCode",
"signal",
"timedOut",
"durationMs",
"stdoutTruncated",
"stderrTruncated",
"createdAt",
"updatedAt",
"startedAt",
"finishedAt",
"completedAt",
"archivedAt",
"fetchedAt",
"counts",
"count",
"files",
"filesChanged",
"transactions",
"command",
"commands",
"sources",
"items",
"nextActionable",
"plan",
"final",
"evidence",
"remaining",
"artifacts",
"resultProtocol",
]);
function isImportantKey(key: string): boolean {
return (
IMPORTANT_KEYS.has(key) ||
/(?:^|_)(?:id|ids|path|paths|url|urls|status|count|counts|sha256)$/i.test(key) ||
/(?:Id|Ids|Path|Paths|Url|Urls|Status|Count|Counts|Sha256)$/.test(key) ||
/^(?:transaction|run|job|task|todo|board|research|source|query|checkpoint)/i.test(key)
);
}
function compactString(value: string, max = 1800): string {
if (value.length <= max) return value;
const head = Math.floor(max * 0.75);
const tail = max - head;
return `${value.slice(0, head)}…[${value.length - max} chars omitted]…${value.slice(-tail)}`;
}
function compactValue(value: unknown, depth = 0, key = ""): unknown {
if (value === undefined) return undefined;
if (value === null || typeof value === "boolean" || typeof value === "number") {
return value;
}
if (typeof value === "string") {
const limit = /(?:summary|objective|message|evidence|remaining)/i.test(key)
? 3000
: 1400;
return compactString(value, limit);
}
if (depth >= 5) return undefined;
if (Array.isArray(value)) {
const maxItems = /(?:source|file|transaction|item|task|evidence|remaining|artifact)/i.test(key)
? 30
: 20;
const items = value
.slice(0, maxItems)
.map((item) => compactValue(item, depth + 1, key))
.filter((item) => item !== undefined);
if (value.length > maxItems) {
items.push({ omittedItems: value.length - maxItems });
}
return items;
}
if (!isRecord(value)) return compactString(String(value), 1000);
const output: Record<string, unknown> = {};
const entries = Object.entries(value);
for (const [childKey, childValue] of entries) {
if (!isImportantKey(childKey)) continue;
const compacted = compactValue(childValue, depth + 1, childKey);
if (compacted !== undefined) output[childKey] = compacted;
}
// Some small objects (for example counts or a tiny final result) have keys
// that are not individually recognizable. Preserve them if inexpensive.
if (Object.keys(output).length === 0 && safeStringify(value).length <= 3000) {
for (const [childKey, childValue] of entries.slice(0, 30)) {
const compacted = compactValue(childValue, depth + 1, childKey);
if (compacted !== undefined) output[childKey] = compacted;
}
}
return Object.keys(output).length > 0 ? output : undefined;
}
function compactArtifacts(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.slice(0, 40).map((artifact) => {
if (!isRecord(artifact)) return compactValue(artifact, 1, "artifact");
const out: Record<string, unknown> = {};
for (const key of ["kind", "path", "sha256", "bytes", "description"]) {
if (artifact[key] !== undefined) out[key] = compactValue(artifact[key], 1, key);
}
return out;
});
}
function criticalRecords(value: unknown): Array<{ path: string; value: unknown }> {
const records: Array<{ path: string; value: unknown }> = [];
const visit = (current: unknown, path: string, depth: number): void => {
if (records.length >= 120 || depth > 6) return;
if (Array.isArray(current)) {
current.slice(0, 40).forEach((item, index) => visit(item, `${path}[${index}]`, depth + 1));
return;
}
if (!isRecord(current)) return;
for (const [key, child] of Object.entries(current)) {
const nextPath = path ? `${path}.${key}` : key;
if (
isImportantKey(key) &&
(typeof child === "string" || typeof child === "number" || typeof child === "boolean")
) {
records.push({ path: nextPath, value: compactValue(child, depth + 1, key) });
} else {
visit(child, nextPath, depth + 1);
}
if (records.length >= 120) return;
}
};
visit(value, "data", 0);
return records;
}
function makeCompactEnvelope(envelope: AgenticEnvelope): Record<string, unknown> {
const cloned = cloneJson(envelope);
const omit = Array.isArray(cloned.retention?.omit_when_summarizing)
? cloned.retention?.omit_when_summarizing
: [];
for (const path of omit) {
if (typeof path === "string") deleteJsonPath(cloned, path);
}
const facts = Array.isArray(cloned.retention?.facts)
? cloned.retention?.facts
.filter((fact): fact is string => typeof fact === "string")
.slice(0, 60)
.map((fact) => compactString(fact, 2200))
: undefined;
const output: Record<string, unknown> = {
protocol: AGENTIC_WORKSPACE_PROTOCOL,
ok: Boolean(cloned.ok),
operation: cloned.operation,
summary: compactString(cloned.summary, 4000),
retention: {
importance: cloned.retention?.importance ?? "normal",
...(facts?.length ? { facts } : {}),
...(omit.length
? {
omittedPayloads: omit
.filter((path): path is string => typeof path === "string")
.slice(0, 40),
}
: {}),
},
};
const artifacts = compactArtifacts(cloned.artifacts);
if (artifacts?.length) output.artifacts = artifacts;
const error = compactValue(cloned.error, 0, "error");
if (error !== undefined) output.error = error;
const data = compactValue(cloned.data, 0, "data");
if (data !== undefined) output.data = data;
return output;
}
/** Return a protocol-aware compact value, or the original value if unrelated. */
export function compactAgenticToolResult(
result: unknown,
maxChars = 12_000,
): unknown {
const parsed = parseEnvelope(result);
if (!parsed) return result;
const compact = makeCompactEnvelope(parsed.envelope);
if (maxChars > 0 && safeStringify(compact).length > maxChars) {
const records = criticalRecords(parsed.envelope.data);
compact.data = {
preservedRecoveryFields: records,
note: "Bulky protocol data was omitted; reopen the durable artifact/state path with Agentic Workspace tools.",
};
if (isRecord(compact.retention) && Array.isArray(compact.retention.facts)) {
compact.retention.facts = compact.retention.facts.slice(0, 30);
}
if (Array.isArray(compact.artifacts)) compact.artifacts = compact.artifacts.slice(0, 30);
}
return parsed.inputWasString ? safeStringify(compact) : compact;
}
/** Render a tool result for the summarizer without feeding it bulky payloads. */
export function renderAgenticToolResultForSummary(
result: unknown,
maxChars = 12_000,
): string {
const compact = compactAgenticToolResult(result, maxChars);
const text = typeof compact === "string" ? compact : safeStringify(compact);
if (maxChars <= 0 || text.length <= maxChars) return text;
const head = Math.max(0, Math.floor(maxChars * 0.75));
const tail = Math.max(0, maxChars - head);
return `${text.slice(0, head)}\n[agentic protocol payload omitted]\n${text.slice(-tail)}`;
}
export function isAgenticWorkspaceResult(value: unknown): boolean {
return parseEnvelope(value) !== undefined;
}
/**
* True when any message in the chunk carries a tool result whose content
* parses as an agentic-workspace/v1 envelope (object or JSON-string form —
* reuses parseEnvelope's own guards, so it's cheap on plain-JSON or prose
* tool results). Used to decide whether the summarizer prompt needs its
* agentic-only sections (Durable workflow state / Source ledger / the
* transaction-ID sentence): a pure function of chunk content, so identical
* chains always regenerate the same prompt and byte-identical summaries.
*/
export function chunkHasAgenticResults(msgs: CanonMessage[]): boolean {
for (const m of msgs) {
for (const r of m.toolResults ?? []) {
if (isAgenticWorkspaceResult(r.content)) return true;
}
}
return false;
}
/**
* Native compaction for Agentic Workspace's durable result envelope.
*
* Generic head/tail truncation can discard transaction IDs, run IDs, task
* state, source ledgers, and artifact paths. This module understands the
* protocol and reduces bulky payloads while preserving those recovery keys.
*/
import { CanonMessage } from "./view";
export const AGENTIC_WORKSPACE_PROTOCOL = "agentic-workspace/v1";
interface AgenticEnvelope {
protocol: typeof AGENTIC_WORKSPACE_PROTOCOL;
ok: boolean;
operation: string;
summary: string;
retention?: {
importance?: string;
facts?: unknown[];
omit_when_summarizing?: unknown[];
};
artifacts?: unknown[];
data?: unknown;
error?: unknown;
[key: string]: unknown;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function parseEnvelope(value: unknown): {
envelope: AgenticEnvelope;
inputWasString: boolean;
} | undefined {
let parsed = value;
const inputWasString = typeof value === "string";
if (inputWasString) {
const text = value.trim();
if (!text.startsWith("{") || !text.includes(AGENTIC_WORKSPACE_PROTOCOL)) {
return undefined;
}
try {
parsed = JSON.parse(text);
} catch {
return undefined;
}
}
if (
!isRecord(parsed) ||
parsed.protocol !== AGENTIC_WORKSPACE_PROTOCOL ||
typeof parsed.operation !== "string" ||
typeof parsed.summary !== "string"
) {
return undefined;
}
return { envelope: parsed as AgenticEnvelope, inputWasString };
}
function cloneJson<T>(value: T): T {
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
function deleteJsonPath(root: unknown, rawPath: string): void {
const parts = rawPath
.replace(/^\$\.?/, "")
.split(".")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0 || !isRecord(root)) return;
let current: Record<string, unknown> = root;
for (let index = 0; index < parts.length - 1; index++) {
const next = current[parts[index]];
if (!isRecord(next)) return;
current = next;
}
delete current[parts[parts.length - 1]];
}
const IMPORTANT_KEYS = new Set([
"id",
"status",
"state",
"mode",
"ok",
"operation",
"summary",
"title",
"objective",
"error",
"code",
"message",
"confidence",
"path",
"url",
"sha256",
"bytes",
"kind",
"description",
"text",
"note",
"priority",
"details",
"dependsOn",
"blockers",
"decisions",
"next",
"query",
"provider",
"excerpt",
"destructive",
"requiresReview",
"added",
"removed",
"beforeSha256",
"afterSha256",
"executable",
"args",
"cwd",
"exitCode",
"signal",
"timedOut",
"durationMs",
"stdoutTruncated",
"stderrTruncated",
"createdAt",
"updatedAt",
"startedAt",
"finishedAt",
"completedAt",
"archivedAt",
"fetchedAt",
"counts",
"count",
"files",
"filesChanged",
"transactions",
"command",
"commands",
"sources",
"items",
"nextActionable",
"plan",
"final",
"evidence",
"remaining",
"artifacts",
"resultProtocol",
]);
function isImportantKey(key: string): boolean {
return (
IMPORTANT_KEYS.has(key) ||
/(?:^|_)(?:id|ids|path|paths|url|urls|status|count|counts|sha256)$/i.test(key) ||
/(?:Id|Ids|Path|Paths|Url|Urls|Status|Count|Counts|Sha256)$/.test(key) ||
/^(?:transaction|run|job|task|todo|board|research|source|query|checkpoint)/i.test(key)
);
}
function compactString(value: string, max = 1800): string {
if (value.length <= max) return value;
const head = Math.floor(max * 0.75);
const tail = max - head;
return `${value.slice(0, head)}…[${value.length - max} chars omitted]…${value.slice(-tail)}`;
}
function compactValue(value: unknown, depth = 0, key = ""): unknown {
if (value === undefined) return undefined;
if (value === null || typeof value === "boolean" || typeof value === "number") {
return value;
}
if (typeof value === "string") {
const limit = /(?:summary|objective|message|evidence|remaining)/i.test(key)
? 3000
: 1400;
return compactString(value, limit);
}
if (depth >= 5) return undefined;
if (Array.isArray(value)) {
const maxItems = /(?:source|file|transaction|item|task|evidence|remaining|artifact)/i.test(key)
? 30
: 20;
const items = value
.slice(0, maxItems)
.map((item) => compactValue(item, depth + 1, key))
.filter((item) => item !== undefined);
if (value.length > maxItems) {
items.push({ omittedItems: value.length - maxItems });
}
return items;
}
if (!isRecord(value)) return compactString(String(value), 1000);
const output: Record<string, unknown> = {};
const entries = Object.entries(value);
for (const [childKey, childValue] of entries) {
if (!isImportantKey(childKey)) continue;
const compacted = compactValue(childValue, depth + 1, childKey);
if (compacted !== undefined) output[childKey] = compacted;
}
// Some small objects (for example counts or a tiny final result) have keys
// that are not individually recognizable. Preserve them if inexpensive.
if (Object.keys(output).length === 0 && safeStringify(value).length <= 3000) {
for (const [childKey, childValue] of entries.slice(0, 30)) {
const compacted = compactValue(childValue, depth + 1, childKey);
if (compacted !== undefined) output[childKey] = compacted;
}
}
return Object.keys(output).length > 0 ? output : undefined;
}
function compactArtifacts(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.slice(0, 40).map((artifact) => {
if (!isRecord(artifact)) return compactValue(artifact, 1, "artifact");
const out: Record<string, unknown> = {};
for (const key of ["kind", "path", "sha256", "bytes", "description"]) {
if (artifact[key] !== undefined) out[key] = compactValue(artifact[key], 1, key);
}
return out;
});
}
function criticalRecords(value: unknown): Array<{ path: string; value: unknown }> {
const records: Array<{ path: string; value: unknown }> = [];
const visit = (current: unknown, path: string, depth: number): void => {
if (records.length >= 120 || depth > 6) return;
if (Array.isArray(current)) {
current.slice(0, 40).forEach((item, index) => visit(item, `${path}[${index}]`, depth + 1));
return;
}
if (!isRecord(current)) return;
for (const [key, child] of Object.entries(current)) {
const nextPath = path ? `${path}.${key}` : key;
if (
isImportantKey(key) &&
(typeof child === "string" || typeof child === "number" || typeof child === "boolean")
) {
records.push({ path: nextPath, value: compactValue(child, depth + 1, key) });
} else {
visit(child, nextPath, depth + 1);
}
if (records.length >= 120) return;
}
};
visit(value, "data", 0);
return records;
}
function makeCompactEnvelope(envelope: AgenticEnvelope): Record<string, unknown> {
const cloned = cloneJson(envelope);
const omit = Array.isArray(cloned.retention?.omit_when_summarizing)
? cloned.retention?.omit_when_summarizing
: [];
for (const path of omit) {
if (typeof path === "string") deleteJsonPath(cloned, path);
}
const facts = Array.isArray(cloned.retention?.facts)
? cloned.retention?.facts
.filter((fact): fact is string => typeof fact === "string")
.slice(0, 60)
.map((fact) => compactString(fact, 2200))
: undefined;
const output: Record<string, unknown> = {
protocol: AGENTIC_WORKSPACE_PROTOCOL,
ok: Boolean(cloned.ok),
operation: cloned.operation,
summary: compactString(cloned.summary, 4000),
retention: {
importance: cloned.retention?.importance ?? "normal",
...(facts?.length ? { facts } : {}),
...(omit.length
? {
omittedPayloads: omit
.filter((path): path is string => typeof path === "string")
.slice(0, 40),
}
: {}),
},
};
const artifacts = compactArtifacts(cloned.artifacts);
if (artifacts?.length) output.artifacts = artifacts;
const error = compactValue(cloned.error, 0, "error");
if (error !== undefined) output.error = error;
const data = compactValue(cloned.data, 0, "data");
if (data !== undefined) output.data = data;
return output;
}
/** Return a protocol-aware compact value, or the original value if unrelated. */
export function compactAgenticToolResult(
result: unknown,
maxChars = 12_000,
): unknown {
const parsed = parseEnvelope(result);
if (!parsed) return result;
const compact = makeCompactEnvelope(parsed.envelope);
if (maxChars > 0 && safeStringify(compact).length > maxChars) {
const records = criticalRecords(parsed.envelope.data);
compact.data = {
preservedRecoveryFields: records,
note: "Bulky protocol data was omitted; reopen the durable artifact/state path with Agentic Workspace tools.",
};
if (isRecord(compact.retention) && Array.isArray(compact.retention.facts)) {
compact.retention.facts = compact.retention.facts.slice(0, 30);
}
if (Array.isArray(compact.artifacts)) compact.artifacts = compact.artifacts.slice(0, 30);
}
return parsed.inputWasString ? safeStringify(compact) : compact;
}
/** Render a tool result for the summarizer without feeding it bulky payloads. */
export function renderAgenticToolResultForSummary(
result: unknown,
maxChars = 12_000,
): string {
const compact = compactAgenticToolResult(result, maxChars);
const text = typeof compact === "string" ? compact : safeStringify(compact);
if (maxChars <= 0 || text.length <= maxChars) return text;
const head = Math.max(0, Math.floor(maxChars * 0.75));
const tail = Math.max(0, maxChars - head);
return `${text.slice(0, head)}\n[agentic protocol payload omitted]\n${text.slice(-tail)}`;
}
export function isAgenticWorkspaceResult(value: unknown): boolean {
return parseEnvelope(value) !== undefined;
}
/**
* True when any message in the chunk carries a tool result whose content
* parses as an agentic-workspace/v1 envelope (object or JSON-string form —
* reuses parseEnvelope's own guards, so it's cheap on plain-JSON or prose
* tool results). Used to decide whether the summarizer prompt needs its
* agentic-only sections (Durable workflow state / Source ledger / the
* transaction-ID sentence): a pure function of chunk content, so identical
* chains always regenerate the same prompt and byte-identical summaries.
*/
export function chunkHasAgenticResults(msgs: CanonMessage[]): boolean {
for (const m of msgs) {
for (const r of m.toolResults ?? []) {
if (isAgenticWorkspaceResult(r.content)) return true;
}
}
return false;
}