Skip to content

Commit bed3e37

Browse files
committed
fix(kimi): isolate share dir per invocation and scope stdout parsing
1 parent ca2f8b1 commit bed3e37

File tree

3 files changed

+237
-11
lines changed

3 files changed

+237
-11
lines changed

src/agents/BaseCliAgent.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ type RunCommandResult = {
8080
exitCode: number | null;
8181
};
8282

83+
type CliCommandSpec = {
84+
command: string;
85+
args: string[];
86+
stdin?: string;
87+
outputFormat?: string;
88+
outputFile?: string;
89+
cleanup?: () => Promise<void>;
90+
env?: Record<string, string>;
91+
stdoutBannerPatterns?: RegExp[];
92+
stdoutErrorPatterns?: RegExp[];
93+
errorOnBannerOnly?: boolean;
94+
};
95+
8396
export function resolveTimeoutMs(
8497
timeout: TimeoutInput,
8598
fallback?: number,
@@ -735,10 +748,13 @@ export abstract class BaseCliAgent implements Agent<any, any, any> {
735748
cwd,
736749
options,
737750
});
751+
const commandEnv = commandSpec.env
752+
? ({ ...env, ...commandSpec.env } as Record<string, string>)
753+
: env;
738754
try {
739755
const result = await runCommand(commandSpec.command, commandSpec.args, {
740756
cwd,
741-
env,
757+
env: commandEnv,
742758
input: commandSpec.stdin,
743759
timeoutMs: callTimeout,
744760
signal: options?.abortSignal,
@@ -775,7 +791,35 @@ export abstract class BaseCliAgent implements Agent<any, any, any> {
775791
throw new Error(errorText);
776792
}
777793

778-
const rawText = stdout.trim();
794+
// Some CLIs may print extra banners to stdout. Allow individual agents
795+
// to provide patterns so this logic stays opt-in and agent-specific.
796+
const stdoutBannerPatterns = commandSpec.stdoutBannerPatterns ?? [];
797+
let cleanedStdout = stdout;
798+
for (const pattern of stdoutBannerPatterns) {
799+
const regex = new RegExp(pattern.source, pattern.flags);
800+
cleanedStdout = cleanedStdout.replace(regex, "");
801+
}
802+
const rawText = cleanedStdout.trim();
803+
804+
// Optionally treat "banner-only" output as an error when requested.
805+
if (commandSpec.errorOnBannerOnly && !rawText && stdout.trim()) {
806+
throw new Error(
807+
`CLI agent error (stdout): output was only a banner with no model response`,
808+
);
809+
}
810+
811+
// Some CLIs report failures on stdout even with exit code 0. Keep
812+
// detection patterns opt-in so normal model text is not misclassified.
813+
const stdoutErrorPatterns = commandSpec.stdoutErrorPatterns ?? [];
814+
if (rawText && !rawText.startsWith("{") && !rawText.startsWith("[")) {
815+
for (const pattern of stdoutErrorPatterns) {
816+
const regex = new RegExp(pattern.source, pattern.flags);
817+
if (regex.test(rawText)) {
818+
throw new Error(`CLI agent error (stdout): ${rawText.slice(0, 500)}`);
819+
}
820+
}
821+
}
822+
779823
const outputFormat = commandSpec.outputFormat;
780824
const extractedText =
781825
outputFormat === "json" || outputFormat === "stream-json"
@@ -805,14 +849,7 @@ export abstract class BaseCliAgent implements Agent<any, any, any> {
805849
systemPrompt?: string;
806850
cwd: string;
807851
options: any;
808-
}): Promise<{
809-
command: string;
810-
args: string[];
811-
stdin?: string;
812-
outputFormat?: string;
813-
outputFile?: string;
814-
cleanup?: () => Promise<void>;
815-
}>;
852+
}): Promise<CliCommandSpec>;
816853
}
817854

818855
export function pushFlag(

src/agents/KimiAgent.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { mkdtempSync, cpSync, existsSync, rmSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { tmpdir, homedir } from "node:os";
14
import {
25
BaseCliAgent,
36
pushFlag,
@@ -40,7 +43,32 @@ export class KimiAgent extends BaseCliAgent {
4043
options: any;
4144
}) {
4245
const args: string[] = [];
43-
const yoloEnabled = this.opts.yolo ?? this.yolo;
46+
let commandEnv: Record<string, string> | undefined;
47+
let cleanup: (() => Promise<void>) | undefined;
48+
49+
// Isolate kimi metadata per invocation to avoid concurrent writes to
50+
// ~/.kimi/kimi.json across parallel tasks. If caller explicitly provides
51+
// KIMI_SHARE_DIR in opts.env, preserve that override.
52+
if (!this.opts.env?.KIMI_SHARE_DIR) {
53+
const defaultShareDir = process.env.KIMI_SHARE_DIR ?? join(homedir(), ".kimi");
54+
const isolatedShareDir = mkdtempSync(join(tmpdir(), "kimi-share-"));
55+
if (existsSync(defaultShareDir)) {
56+
for (const name of ["config.toml", "credentials", "device_id", "latest_version.txt"]) {
57+
const src = join(defaultShareDir, name);
58+
if (existsSync(src)) {
59+
try {
60+
cpSync(src, join(isolatedShareDir, name), { recursive: true });
61+
} catch {
62+
// Best-effort seed only; missing copy should not prevent execution.
63+
}
64+
}
65+
}
66+
}
67+
commandEnv = { KIMI_SHARE_DIR: isolatedShareDir };
68+
cleanup = async () => {
69+
rmSync(isolatedShareDir, { recursive: true, force: true });
70+
};
71+
}
4472

4573
// Print mode is required for non-interactive execution
4674
// Note: --print implicitly adds --yolo
@@ -93,6 +121,18 @@ export class KimiAgent extends BaseCliAgent {
93121
command: "kimi",
94122
args,
95123
outputFormat,
124+
env: commandEnv,
125+
cleanup,
126+
stdoutBannerPatterns: [/^YOLO mode is enabled\b[^\n]*/gm],
127+
stdoutErrorPatterns: [
128+
/^LLM not set/i,
129+
/^LLM not supported/i,
130+
/^Max steps reached/i,
131+
/^Interrupted by user$/i,
132+
/^Unknown error:/i,
133+
/^Error:/i,
134+
],
135+
errorOnBannerOnly: true,
96136
};
97137
}
98138
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { BaseCliAgent } from "../src/agents/BaseCliAgent";
3+
4+
type StdoutHandling = {
5+
stdoutBannerPatterns?: RegExp[];
6+
stdoutErrorPatterns?: RegExp[];
7+
errorOnBannerOnly?: boolean;
8+
};
9+
10+
/**
11+
* Test agent that writes a fixed string to stdout and exits 0.
12+
* Optional stdout handling patterns emulate agent-specific parsing behavior.
13+
*/
14+
class StdoutAgent extends BaseCliAgent {
15+
constructor(
16+
private readonly stdoutText: string,
17+
private readonly handling: StdoutHandling = {},
18+
) {
19+
super({ id: "stdout-test-agent" });
20+
}
21+
22+
protected async buildCommand(_params: {
23+
prompt: string;
24+
systemPrompt?: string;
25+
cwd: string;
26+
options: any;
27+
}) {
28+
return {
29+
command: "printf",
30+
args: ["%s", this.stdoutText],
31+
...this.handling,
32+
};
33+
}
34+
}
35+
36+
describe("BaseCliAgent stdout handling defaults", () => {
37+
test("does not treat generic 'Error:' text as CLI failure by default", async () => {
38+
const agent = new StdoutAgent("Error: this is model-authored text");
39+
const result = await agent.generate({ prompt: "test" });
40+
expect(result.text).toBe("Error: this is model-authored text");
41+
});
42+
43+
test("does not strip YOLO banner by default", async () => {
44+
const agent = new StdoutAgent(
45+
"YOLO mode is enabled. All tool calls will be automatically approved.",
46+
);
47+
const result = await agent.generate({ prompt: "test" });
48+
expect(result.text).toContain("YOLO mode is enabled");
49+
});
50+
});
51+
52+
describe("BaseCliAgent stdout handling (opt-in)", () => {
53+
const kimiErrorPatterns = [
54+
/^LLM not set/i,
55+
/^LLM not supported/i,
56+
/^Max steps reached/i,
57+
/^Interrupted by user$/i,
58+
/^Unknown error:/i,
59+
/^Error:/i,
60+
];
61+
62+
const errorCases = [
63+
"LLM not set",
64+
"LLM not supported",
65+
"Max steps reached: 50",
66+
"Interrupted by user",
67+
"Unknown error: connection refused",
68+
"Error: something went wrong",
69+
"error: lowercase variant",
70+
];
71+
72+
for (const errorText of errorCases) {
73+
test(`throws for stdout error pattern: "${errorText}"`, async () => {
74+
const agent = new StdoutAgent(errorText, {
75+
stdoutErrorPatterns: kimiErrorPatterns,
76+
});
77+
await expect(agent.generate({ prompt: "test" })).rejects.toThrow(
78+
"CLI agent error (stdout):",
79+
);
80+
});
81+
}
82+
83+
test("does not throw for valid JSON output", async () => {
84+
const agent = new StdoutAgent('{"result": "ok"}', {
85+
stdoutErrorPatterns: kimiErrorPatterns,
86+
});
87+
const result = await agent.generate({ prompt: "test" });
88+
expect(result.text).toContain("ok");
89+
});
90+
91+
test("does not throw for JSON array output", async () => {
92+
const agent = new StdoutAgent('[{"id": 1}]', {
93+
stdoutErrorPatterns: kimiErrorPatterns,
94+
});
95+
const result = await agent.generate({ prompt: "test" });
96+
expect(result.text).toContain("id");
97+
});
98+
99+
test("does not throw for normal text output", async () => {
100+
const agent = new StdoutAgent("Hello, this is a normal response", {
101+
stdoutErrorPatterns: kimiErrorPatterns,
102+
});
103+
const result = await agent.generate({ prompt: "test" });
104+
expect(result.text).toBe("Hello, this is a normal response");
105+
});
106+
});
107+
108+
describe("BaseCliAgent banner handling (opt-in)", () => {
109+
const yoloBanner = /^YOLO mode is enabled\b[^\n]*/gm;
110+
111+
test("throws when stdout is only the banner", async () => {
112+
const agent = new StdoutAgent(
113+
"YOLO mode is enabled. All tool calls will be automatically approved.",
114+
{
115+
stdoutBannerPatterns: [yoloBanner],
116+
errorOnBannerOnly: true,
117+
},
118+
);
119+
await expect(agent.generate({ prompt: "test" })).rejects.toThrow(
120+
"CLI agent error (stdout):",
121+
);
122+
});
123+
124+
test("strips banner and returns remaining JSON", async () => {
125+
const content = [
126+
"YOLO mode is enabled. All tool calls will be automatically approved.",
127+
'{"result": "actual model output"}',
128+
].join("\n");
129+
const agent = new StdoutAgent(content, {
130+
stdoutBannerPatterns: [yoloBanner],
131+
errorOnBannerOnly: true,
132+
});
133+
const result = await agent.generate({ prompt: "test" });
134+
expect(result.text).toContain("actual model output");
135+
});
136+
137+
test("strips banner when followed by plain text", async () => {
138+
const content = [
139+
"YOLO mode is enabled. All tool calls will be automatically approved.",
140+
"Here is the actual response from the model.",
141+
].join("\n");
142+
const agent = new StdoutAgent(content, {
143+
stdoutBannerPatterns: [yoloBanner],
144+
errorOnBannerOnly: true,
145+
});
146+
const result = await agent.generate({ prompt: "test" });
147+
expect(result.text).toBe("Here is the actual response from the model.");
148+
});
149+
});

0 commit comments

Comments
 (0)