Skip to content

Commit b1a4e8e

Browse files
authored
Merge pull request #168 from biquanha/fix/workflow-prompt-cache
2 parents e896c9b + 67bc76f commit b1a4e8e

4 files changed

Lines changed: 190 additions & 14 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Claude Cache Stability
2+
3+
on: [pull_request, push]
4+
5+
jobs:
6+
claude-cache-stability:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- name: Check out repository
10+
uses: actions/checkout@v4
11+
12+
- name: Set up Node.js
13+
uses: actions/setup-node@v4
14+
with:
15+
node-version: 22
16+
cache: npm
17+
18+
- name: Install dependencies
19+
run: npm ci
20+
21+
- name: Run Claude cache stability test
22+
run: npm run test:cache

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"smoke:btc": "tsx scripts/real-btc-smoke.ts",
1919
"smoke:nested-market": "npm run build && tsx scripts/nested-market-smoke.ts",
2020
"test": "vitest run --config vitest.config.ts",
21-
"typecheck": "tsc -p tsconfig.json --noEmit"
21+
"typecheck": "tsc -p tsconfig.json --noEmit",
22+
"test:cache": "vitest run --config vitest.config.ts tests/claude-cache-stability.test.ts"
2223
},
2324
"dependencies": {
2425
"@modelcontextprotocol/sdk": "^1.23.0",

src/agents/workflow-context.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,28 @@ export function promptWithWorkflowContext(prompt: string, context: WorkflowAgent
55
return prompt;
66
}
77
return [
8+
"Humanize2 workflow agent instructions:",
9+
"- You are running as a Humanize2-managed agent.",
10+
"- Read the workflow context block after the task before acting.",
11+
"- vertexId is the workflow node identity for artifact ownership and routing;",
12+
"- shortName is the human-facing agent/session alias and should not replace vertexId in workflow state.",
13+
"Deliver expected artifacts back to Humanize2 through the listed MCP tools or JSON-RPC endpoint.",
14+
"Do not inspect, signal, attach to, or mutate the Humanize2 hub process or its in-memory runtime state.",
15+
"Do not repair workflow state directly; use Humanize2 artifact, board, event, message, or view APIs.",
16+
"",
17+
"Task:",
18+
prompt,
19+
"",
820
"Humanize2 workflow context:",
921
`- workflowRunId: ${context.workflowRunId}`,
1022
`- vertexId: ${context.vertexId}`,
1123
`- shortName: ${context.shortName}`,
12-
"- vertexId is the workflow node identity for artifact ownership and routing;",
13-
"- shortName is the human-facing agent/session alias and should not replace vertexId in workflow state.",
1424
`- jsonRpcUrl: ${context.jsonRpcUrl}`,
15-
`- expectedArtifacts: ${JSON.stringify(context.expectedArtifacts)}`,
16-
`- inputs: ${JSON.stringify(context.inputs ?? [])}`,
25+
`- expectedArtifacts: ${stableJson(context.expectedArtifacts)}`,
26+
`- inputs: ${stableJson(context.inputs ?? [])}`,
1727
`- mcpToolNames: ${context.mcpToolNames.join(", ")}`,
1828
"",
19-
...inputSnapshotSection(context),
20-
"Deliver expected artifacts back to Humanize2 through the listed MCP tools or JSON-RPC endpoint.",
21-
"Do not inspect, signal, attach to, or mutate the Humanize2 hub process or its in-memory runtime state.",
22-
"Do not repair workflow state directly; use Humanize2 artifact, board, event, message, or view APIs.",
23-
"",
24-
prompt
29+
...inputSnapshotSection(context)
2530
].join("\n");
2631
}
2732

@@ -38,8 +43,8 @@ export function environmentWithWorkflowContext(
3843
HUMANIZE2_WORKFLOW_VERTEX_ID: context.vertexId,
3944
HUMANIZE2_WORKFLOW_SHORT_NAME: context.shortName,
4045
HUMANIZE2_WORKFLOW_JSONRPC_URL: context.jsonRpcUrl,
41-
HUMANIZE2_WORKFLOW_EXPECTED_ARTIFACTS: JSON.stringify(context.expectedArtifacts),
42-
HUMANIZE2_WORKFLOW_INPUTS: JSON.stringify(context.inputs ?? []),
46+
HUMANIZE2_WORKFLOW_EXPECTED_ARTIFACTS: stableJson(context.expectedArtifacts),
47+
HUMANIZE2_WORKFLOW_INPUTS: stableJson(context.inputs ?? []),
4348
HUMANIZE2_WORKFLOW_MCP_TOOLS: context.mcpToolNames.join(",")
4449
};
4550
}
@@ -50,8 +55,26 @@ function inputSnapshotSection(context: WorkflowAgentLaunchContext): string[] {
5055
}
5156
return [
5257
"Declared workflow input snapshots:",
53-
JSON.stringify(context.inputs, null, 2),
58+
stableJson(context.inputs, 2),
5459
"Treat these input snapshots as part of the current task contract.",
5560
""
5661
];
5762
}
63+
64+
function stableJson(value: unknown, space?: number): string {
65+
return JSON.stringify(stableJsonValue(value), null, space);
66+
}
67+
68+
function stableJsonValue(value: unknown): unknown {
69+
if (Array.isArray(value)) {
70+
return value.map(stableJsonValue);
71+
}
72+
if (value === null || typeof value !== "object") {
73+
return value;
74+
}
75+
76+
const object = value as Record<string, unknown>;
77+
return Object.fromEntries(
78+
Object.keys(object).sort().map((key) => [key, stableJsonValue(object[key])])
79+
);
80+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { promptWithWorkflowContext } from "../src/agents/workflow-context.js";
4+
import type { WorkflowAgentLaunchContext } from "../src/agents/types.js";
5+
6+
describe("Claude workflow prompt cache stability", () => {
7+
it("keeps more than 90 percent of prompt bytes reusable across 100 dynamic workflow turns", () => {
8+
const claudeCodeVersion = "2.1.143";
9+
const rounds = 100;
10+
const taskPrompt = [
11+
"Implement the requested workflow task using the declared artifacts.",
12+
stableTaskBody()
13+
].join("\n\n");
14+
const prompts = Array.from({ length: rounds }, (_, index) =>
15+
withSameClaudeCodeVersionEnvelope(
16+
claudeCodeVersion,
17+
promptWithWorkflowContext(taskPrompt, contextForTurn(index))
18+
)
19+
);
20+
21+
const cache = estimateCacheStability(prompts);
22+
23+
expect(cache.claudeCodeVersion).toBe(claudeCodeVersion);
24+
expect(cache.rounds).toBe(rounds);
25+
expect(cache.averagePromptBytes).toBeGreaterThan(10_000);
26+
expect(cache.averageReusablePrefixBytes).toBeGreaterThan(10_000);
27+
expect(cache.cacheHitRatio).toBeGreaterThan(0.9);
28+
});
29+
});
30+
31+
interface CacheEstimate {
32+
claudeCodeVersion: string;
33+
rounds: number;
34+
averagePromptBytes: number;
35+
averageReusablePrefixBytes: number;
36+
cacheHitRatio: number;
37+
}
38+
39+
function withSameClaudeCodeVersionEnvelope(claudeCodeVersion: string, prompt: string): string {
40+
return [
41+
`Claude Code version: ${claudeCodeVersion}`,
42+
"Model: gpt-5.5",
43+
"Permission mode: bypassPermissions",
44+
"Output format: stream-json",
45+
"",
46+
prompt
47+
].join("\n");
48+
}
49+
50+
function estimateCacheStability(prompts: string[]): CacheEstimate {
51+
const reusablePrefixBytes = prompts.slice(1).map((prompt, index) =>
52+
commonPrefixLength(prompts[index], prompt)
53+
);
54+
const promptBytes = prompts.map((prompt) => prompt.length);
55+
const averagePromptBytes = average(promptBytes);
56+
const averageReusablePrefixBytes = average(reusablePrefixBytes);
57+
const version = /^Claude Code version: (.+)$/m.exec(prompts[0])?.[1] ?? "unknown";
58+
59+
return {
60+
claudeCodeVersion: version,
61+
rounds: prompts.length,
62+
averagePromptBytes,
63+
averageReusablePrefixBytes,
64+
cacheHitRatio: averageReusablePrefixBytes / averagePromptBytes
65+
};
66+
}
67+
68+
function contextForTurn(index: number): WorkflowAgentLaunchContext {
69+
return {
70+
workflowRunId: `workflow-run-${index.toString().padStart(3, "0")}`,
71+
vertexId: `reviewer-${index % 7}`,
72+
shortName: `reviewer-${index % 5}`,
73+
jsonRpcUrl: `http://127.0.0.1:${4772 + index}/jsonrpc`,
74+
expectedArtifacts: [{
75+
schema: "rlcr.verdict.v1",
76+
name: "verdict"
77+
}],
78+
inputs: [{
79+
kind: "artifact",
80+
name: "draft",
81+
schema: "draft.v1",
82+
label: "Current draft",
83+
optional: false,
84+
producer: `builder-${index}`,
85+
iteration: index + 1,
86+
createdAt: `2026-05-16T10:${String(index % 60).padStart(2, "0")}:00.000Z`,
87+
content: {
88+
b: 2,
89+
a: 1,
90+
turn: index
91+
}
92+
}, {
93+
kind: "board",
94+
id: "loop-status",
95+
label: "Loop status",
96+
optional: true,
97+
updatedAt: `2026-05-16T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
98+
value: {
99+
status: index % 2 === 0 ? "revise" : "review",
100+
requiredFollowUp: [`Fix-${index}`]
101+
}
102+
}],
103+
mcpToolNames: [
104+
"artifact_deliver",
105+
"workflow_get",
106+
"board_patch",
107+
"event_emit"
108+
]
109+
};
110+
}
111+
112+
function stableTaskBody(): string {
113+
return Array.from({ length: 120 }, (_, index) =>
114+
`STABLE_TASK_LINE_${String(index + 1).padStart(3, "0")}: This deterministic task body represents reusable workflow instructions and stays unchanged across turns.`
115+
).join("\n");
116+
}
117+
118+
function average(values: number[]): number {
119+
return values.reduce((total, value) => total + value, 0) / values.length;
120+
}
121+
122+
function commonPrefixLength(left: string, right: string): number {
123+
const limit = Math.min(left.length, right.length);
124+
for (let index = 0; index < limit; index += 1) {
125+
if (left[index] !== right[index]) {
126+
return index;
127+
}
128+
}
129+
return limit;
130+
}

0 commit comments

Comments
 (0)