@smithers-orchestrator/agents 0.16.9 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/AgentLike.ts +4 -1
- package/src/AmpAgent.js +2 -0
- package/src/AnthropicAgent.js +10 -3
- package/src/BaseCliAgent/AgentGenerateOptions.ts +24 -0
- package/src/BaseCliAgent/BaseCliAgent.js +166 -32
- package/src/BaseCliAgent/extractPrompt.js +0 -1
- package/src/BaseCliAgent/extractTextFromJsonValue.js +2 -0
- package/src/BaseCliAgent/index.js +1 -0
- package/src/ClaudeCodeAgent.js +10 -3
- package/src/ClaudeCodeAgentOptions.ts +16 -0
- package/src/CodexAgent.js +6 -0
- package/src/CodexAgentOptions.ts +15 -0
- package/src/ForgeAgent.js +2 -0
- package/src/GeminiAgent.js +6 -2
- package/src/GeminiAgentOptions.ts +12 -0
- package/src/KimiAgent.js +211 -6
- package/src/KimiAgentOptions.ts +8 -0
- package/src/OpenAIAgent.js +10 -3
- package/src/OpenCodeAgent.js +495 -0
- package/src/OpenCodeAgent.ts +43 -0
- package/src/PiAgent.js +1 -1
- package/src/__type-tests__/AgentLike.assignability.test-d.ts +31 -0
- package/src/capability-registry/AgentCapabilityRegistry.ts +1 -1
- package/src/cli-capabilities/CliAgentCapabilityAdapterId.ts +1 -0
- package/src/cli-capabilities/getCliAgentCapabilityReport.js +6 -0
- package/src/index.d.ts +65 -64
- package/src/index.js +3 -0
package/src/KimiAgent.js
CHANGED
|
@@ -1,9 +1,190 @@
|
|
|
1
|
-
import { mkdtempSync, cpSync, existsSync, rmSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, cpSync, existsSync, readFileSync, writeFileSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir, homedir } from "node:os";
|
|
5
5
|
import { BaseCliAgent, pushFlag, pushList, isRecord, asString, toolKindFromName, createSyntheticIdGenerator, } from "./BaseCliAgent/index.js";
|
|
6
6
|
import { normalizeCapabilityStringList, } from "./capability-registry/index.js";
|
|
7
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The kimi CLI's OAuth refresh endpoint, mirroring the Python implementation
|
|
11
|
+
* in `kimi_cli.auth.oauth.refresh_token`. Honour the same env-var override
|
|
12
|
+
* the CLI uses (KIMI_OAUTH_HOST) so test/staging overrides keep working.
|
|
13
|
+
*/
|
|
14
|
+
function kimiOAuthHost() {
|
|
15
|
+
return process.env.KIMI_OAUTH_HOST?.replace(/\/+$/, "") || "https://auth.kimi.com";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Process-level dedup map: if multiple parallel KimiAgent invocations land in
|
|
20
|
+
* the refresh path concurrently, share one in-flight Promise so we issue a
|
|
21
|
+
* single POST instead of racing (kimi rotates refresh tokens — only one
|
|
22
|
+
* refresh wins, the other gets invalid_grant and would be wrongly classified
|
|
23
|
+
* as expired).
|
|
24
|
+
*
|
|
25
|
+
* @type {Map<string, Promise<void>>}
|
|
26
|
+
*/
|
|
27
|
+
const inflightRefreshes = new Map();
|
|
28
|
+
|
|
29
|
+
async function refreshKimiTokenIfNeeded(credsDir, fileName) {
|
|
30
|
+
const path = join(credsDir, fileName);
|
|
31
|
+
/** @type {{access_token?: string, refresh_token?: string, expires_at?: number, token_type?: string, scope?: string} | null} */
|
|
32
|
+
let data = null;
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { ok: false, reason: "unreadable", expiredAt: null };
|
|
38
|
+
}
|
|
39
|
+
if (!data || typeof data.expires_at !== "number") {
|
|
40
|
+
// Not an OAuth file — leave alone, kimi will handle.
|
|
41
|
+
return { ok: true, refreshed: false };
|
|
42
|
+
}
|
|
43
|
+
// Refresh proactively a bit before expiry to avoid races (matches the
|
|
44
|
+
// CLI's _refresh_threshold behaviour, simplified to a fixed 60s window).
|
|
45
|
+
const nowSec = Date.now() / 1000;
|
|
46
|
+
if (data.expires_at - 60 > nowSec) {
|
|
47
|
+
return { ok: true, refreshed: false };
|
|
48
|
+
}
|
|
49
|
+
if (typeof data.refresh_token !== "string" || data.refresh_token.length === 0) {
|
|
50
|
+
return { ok: false, reason: "no-refresh-token", expiredAt: new Date(data.expires_at * 1000).toISOString() };
|
|
51
|
+
}
|
|
52
|
+
// Dedupe concurrent refreshes per-credential-file within this process.
|
|
53
|
+
const flightKey = path;
|
|
54
|
+
const inflight = inflightRefreshes.get(flightKey);
|
|
55
|
+
if (inflight) {
|
|
56
|
+
try {
|
|
57
|
+
await inflight;
|
|
58
|
+
return { ok: true, refreshed: true, deduped: true };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
return { ok: false, reason: err?.message ?? "refresh-failed", expiredAt: new Date(data.expires_at * 1000).toISOString() };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const refresher = (async () => {
|
|
65
|
+
const tokenUrl = `${kimiOAuthHost()}/api/oauth/token`;
|
|
66
|
+
// client_id matches kimi_cli.auth.oauth.KIMI_CODE_CLIENT_ID. Without it the
|
|
67
|
+
// /api/oauth/token endpoint returns 400 invalid_request.
|
|
68
|
+
const body = new URLSearchParams({
|
|
69
|
+
client_id: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
|
70
|
+
grant_type: "refresh_token",
|
|
71
|
+
refresh_token: data.refresh_token,
|
|
72
|
+
});
|
|
73
|
+
// Mirror kimi-cli's `_common_headers()` so the auth service treats this
|
|
74
|
+
// refresh as coming from a legitimate kimi-cli install. Some of these
|
|
75
|
+
// (notably X-Msh-Device-Id) appear to gate refresh acceptance.
|
|
76
|
+
const headers = {
|
|
77
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
78
|
+
"X-Msh-Platform": "kimi_cli",
|
|
79
|
+
"X-Msh-Version": "1.37.0",
|
|
80
|
+
"X-Msh-Device-Name": "smithers-orchestrator",
|
|
81
|
+
"X-Msh-Device-Model": "smithers-orchestrator",
|
|
82
|
+
"X-Msh-Os-Version": process.platform,
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const deviceIdPath = join(credsDir, "..", "device_id");
|
|
86
|
+
if (existsSync(deviceIdPath)) {
|
|
87
|
+
const deviceId = readFileSync(deviceIdPath, "utf8").trim();
|
|
88
|
+
if (deviceId)
|
|
89
|
+
headers["X-Msh-Device-Id"] = deviceId;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch { /* device-id is optional */ }
|
|
93
|
+
const resp = await fetch(tokenUrl, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers,
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
if (!resp.ok) {
|
|
99
|
+
const text = await resp.text().catch(() => "");
|
|
100
|
+
const tag = resp.status === 401 ? "invalid_grant" : `http-${resp.status}`;
|
|
101
|
+
throw new Error(`kimi oauth refresh failed (${tag}): ${text.slice(0, 200)}`);
|
|
102
|
+
}
|
|
103
|
+
/** @type {any} */
|
|
104
|
+
const fresh = await resp.json();
|
|
105
|
+
if (typeof fresh?.access_token !== "string") {
|
|
106
|
+
throw new Error("kimi oauth refresh: missing access_token in response");
|
|
107
|
+
}
|
|
108
|
+
const expiresIn = typeof fresh.expires_in === "number"
|
|
109
|
+
? fresh.expires_in
|
|
110
|
+
: 3600;
|
|
111
|
+
const merged = {
|
|
112
|
+
...data,
|
|
113
|
+
access_token: fresh.access_token,
|
|
114
|
+
refresh_token: typeof fresh.refresh_token === "string" ? fresh.refresh_token : data.refresh_token,
|
|
115
|
+
token_type: typeof fresh.token_type === "string" ? fresh.token_type : data.token_type,
|
|
116
|
+
scope: typeof fresh.scope === "string" ? fresh.scope : data.scope,
|
|
117
|
+
expires_in: expiresIn,
|
|
118
|
+
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
|
|
119
|
+
};
|
|
120
|
+
// Write atomically so kimi-cli reading concurrently never sees a torn file.
|
|
121
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
122
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
123
|
+
renameSync(tmp, path);
|
|
124
|
+
})();
|
|
125
|
+
inflightRefreshes.set(flightKey, refresher);
|
|
126
|
+
try {
|
|
127
|
+
await refresher;
|
|
128
|
+
return { ok: true, refreshed: true };
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return { ok: false, reason: err?.message ?? "refresh-failed", expiredAt: new Date(data.expires_at * 1000).toISOString() };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
inflightRefreshes.delete(flightKey);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Inspect the kimi CLI's on-disk OAuth credentials and, if any are expired,
|
|
140
|
+
* attempt a non-interactive refresh against `${KIMI_OAUTH_HOST or
|
|
141
|
+
* https://auth.kimi.com}/api/oauth/token` using the stored refresh_token.
|
|
142
|
+
* Only if refresh fails do we surface a clear, non-retryable
|
|
143
|
+
* AGENT_CONFIG_INVALID error.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} shareDir
|
|
146
|
+
* @param {string} agentId
|
|
147
|
+
* @param {string} agentModel
|
|
148
|
+
*/
|
|
149
|
+
async function ensureKimiCredentialsUsable(shareDir, agentId, agentModel) {
|
|
150
|
+
const credsDir = join(shareDir, "credentials");
|
|
151
|
+
if (!existsSync(credsDir))
|
|
152
|
+
return; // No creds dir → kimi will print "LLM not set" which the BaseCliAgent classifier handles.
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = readdirSync(credsDir);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const tokenFiles = entries.filter((n) => n.endsWith(".json"));
|
|
161
|
+
if (tokenFiles.length === 0)
|
|
162
|
+
return;
|
|
163
|
+
let lastFailure = null;
|
|
164
|
+
let anyUsable = false;
|
|
165
|
+
for (const name of tokenFiles) {
|
|
166
|
+
const result = await refreshKimiTokenIfNeeded(credsDir, name);
|
|
167
|
+
if (result.ok) {
|
|
168
|
+
anyUsable = true;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
lastFailure = result;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!anyUsable && lastFailure) {
|
|
175
|
+
const reason = lastFailure.reason === "no-refresh-token"
|
|
176
|
+
? `OAuth token expired at ${lastFailure.expiredAt} and no refresh_token is stored`
|
|
177
|
+
: `OAuth token expired at ${lastFailure.expiredAt}; auto-refresh failed: ${lastFailure.reason}`;
|
|
178
|
+
throw new SmithersError("AGENT_CONFIG_INVALID", `${reason}. Run \`kimi login\` to re-authenticate, then resume the run. (agent="${agentId}", model="${agentModel}", credentials="${credsDir}")`, {
|
|
179
|
+
failureRetryable: false,
|
|
180
|
+
agentId,
|
|
181
|
+
agentEngine: "kimi",
|
|
182
|
+
agentModel,
|
|
183
|
+
command: "kimi",
|
|
184
|
+
underlying: reason,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
7
188
|
/** @typedef {import("./BaseCliAgent/BaseCliAgentOptions.ts").BaseCliAgentOptions} BaseCliAgentOptions */
|
|
8
189
|
/** @typedef {import("./capability-registry/AgentCapabilityRegistry.ts").AgentCapabilityRegistry} AgentCapabilityRegistry */
|
|
9
190
|
/** @typedef {import("./BaseCliAgent/CliOutputInterpreter.ts").CliOutputInterpreter} CliOutputInterpreter */
|
|
@@ -165,13 +346,18 @@ export class KimiAgent extends BaseCliAgent {
|
|
|
165
346
|
let cleanup;
|
|
166
347
|
// Isolate kimi metadata per invocation to avoid concurrent writes to
|
|
167
348
|
// ~/.kimi/kimi.json across parallel tasks. If caller explicitly provides
|
|
168
|
-
// KIMI_SHARE_DIR in opts.env, preserve that override.
|
|
169
|
-
|
|
170
|
-
|
|
349
|
+
// configDir or KIMI_SHARE_DIR in opts.env, preserve that override.
|
|
350
|
+
const explicitShareDir = this.opts.configDir ?? this.opts.env?.KIMI_SHARE_DIR;
|
|
351
|
+
const sourceShareDir = explicitShareDir ?? process.env.KIMI_SHARE_DIR ?? join(homedir(), ".kimi");
|
|
352
|
+
// Refresh expired OAuth credentials in place using the stored refresh_token,
|
|
353
|
+
// and only fail fast (non-retryable) if the refresh itself fails. This avoids
|
|
354
|
+
// forcing the user to run `kimi login` every time their access_token rotates.
|
|
355
|
+
await ensureKimiCredentialsUsable(sourceShareDir, this.id ?? "<anonymous>", this.opts.model ?? this.model ?? "<unset>");
|
|
356
|
+
if (!explicitShareDir) {
|
|
171
357
|
const isolatedShareDir = mkdtempSync(join(tmpdir(), "kimi-share-"));
|
|
172
|
-
if (existsSync(
|
|
358
|
+
if (existsSync(sourceShareDir)) {
|
|
173
359
|
for (const name of ["config.toml", "credentials", "device_id", "latest_version.txt"]) {
|
|
174
|
-
const src = join(
|
|
360
|
+
const src = join(sourceShareDir, name);
|
|
175
361
|
if (existsSync(src)) {
|
|
176
362
|
try {
|
|
177
363
|
cpSync(src, join(isolatedShareDir, name), { recursive: true });
|
|
@@ -187,6 +373,11 @@ export class KimiAgent extends BaseCliAgent {
|
|
|
187
373
|
rmSync(isolatedShareDir, { recursive: true, force: true });
|
|
188
374
|
};
|
|
189
375
|
}
|
|
376
|
+
else if (this.opts.configDir) {
|
|
377
|
+
// configDir takes precedence over any env-var inheritance: spawn the
|
|
378
|
+
// CLI with KIMI_SHARE_DIR pointing at the user-specified path.
|
|
379
|
+
commandEnv = { KIMI_SHARE_DIR: this.opts.configDir };
|
|
380
|
+
}
|
|
190
381
|
// Print mode is required for non-interactive execution
|
|
191
382
|
// Note: --print implicitly adds --yolo
|
|
192
383
|
args.push("--print");
|
|
@@ -253,6 +444,20 @@ export class KimiAgent extends BaseCliAgent {
|
|
|
253
444
|
/^Interrupted by user$/i,
|
|
254
445
|
/^Unknown error:/i,
|
|
255
446
|
/^Error:/i,
|
|
447
|
+
// Auth failures kimi prints when OAuth/api-key is invalid or expired.
|
|
448
|
+
// BaseCliAgent's classifyNonRetryableAgentError treats these as
|
|
449
|
+
// non-retryable so the run does not waste turns on a 401 loop.
|
|
450
|
+
/Error code:\s*401\b[^\n]*/i,
|
|
451
|
+
/\binvalid_authentication_error\b/i,
|
|
452
|
+
/API\s*Key\s+appears to be invalid or may have expired/i,
|
|
453
|
+
],
|
|
454
|
+
// The kimi CLI emits "To resume this session: kimi -r <id>" to stderr
|
|
455
|
+
// on every non-zero exit (it's a hint for interactive users, not the
|
|
456
|
+
// actual error). Strip it so the real underlying error surfaces — and
|
|
457
|
+
// when it's the only stderr content, our runner will fall back to a
|
|
458
|
+
// useful "exited with code N" message that the engine can retry.
|
|
459
|
+
benignStderrPatterns: [
|
|
460
|
+
/^\s*To resume this session: kimi -r [0-9a-f-]+\s*$/gim,
|
|
256
461
|
],
|
|
257
462
|
errorOnBannerOnly: true,
|
|
258
463
|
};
|
package/src/KimiAgentOptions.ts
CHANGED
|
@@ -18,4 +18,12 @@ export type KimiAgentOptions = BaseCliAgentOptions & {
|
|
|
18
18
|
maxRalphIterations?: number;
|
|
19
19
|
verbose?: boolean;
|
|
20
20
|
debug?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Path to an isolated Kimi share directory. Sets `KIMI_SHARE_DIR` on the
|
|
23
|
+
* spawned process so this invocation reads/writes credentials at
|
|
24
|
+
* `<configDir>/credentials` (instead of the user's default `~/.kimi/`).
|
|
25
|
+
* Equivalent to passing `env: { KIMI_SHARE_DIR: <path> }` but uniform with
|
|
26
|
+
* the other agents' `configDir` option.
|
|
27
|
+
*/
|
|
28
|
+
configDir?: string;
|
|
21
29
|
};
|
package/src/OpenAIAgent.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { openai } from "@ai-sdk/openai";
|
|
2
|
-
import { ToolLoopAgent, } from "ai";
|
|
2
|
+
import { Output, ToolLoopAgent, } from "ai";
|
|
3
3
|
import { resolveSdkModel } from "./resolveSdkModel.js";
|
|
4
4
|
import { streamResultToGenerateResult } from "./streamResultToGenerateResult.js";
|
|
5
5
|
/** @typedef {import("ai").AgentCallParameters} AgentCallParameters */
|
|
6
|
+
/** @typedef {import("./BaseCliAgent/AgentGenerateOptions.ts").AgentGenerateOptions} AgentGenerateOptions */
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @template CALL_OPTIONS, TOOLS
|
|
@@ -16,6 +17,7 @@ import { streamResultToGenerateResult } from "./streamResultToGenerateResult.js"
|
|
|
16
17
|
|
|
17
18
|
export class OpenAIAgent extends ToolLoopAgent {
|
|
18
19
|
hijackEngine = "openai-sdk";
|
|
20
|
+
supportsNativeStructuredOutput = true;
|
|
19
21
|
/**
|
|
20
22
|
* @param {OpenAIAgentOptions<CALL_OPTIONS, TOOLS>} opts
|
|
21
23
|
*/
|
|
@@ -27,18 +29,22 @@ export class OpenAIAgent extends ToolLoopAgent {
|
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
|
-
* @param {
|
|
32
|
+
* @param {AgentGenerateOptions} [args]
|
|
31
33
|
* @returns {Promise<GenerateTextResult<TOOLS, never>>}
|
|
32
34
|
*/
|
|
33
|
-
generate(args) {
|
|
35
|
+
generate(args = {}) {
|
|
34
36
|
const promptArgs = "messages" in args
|
|
35
37
|
? { messages: args.messages }
|
|
36
38
|
: { prompt: args.prompt };
|
|
39
|
+
const outputArgs = args.outputSchema
|
|
40
|
+
? { output: Output.object({ schema: args.outputSchema }) }
|
|
41
|
+
: {};
|
|
37
42
|
if (!args.onStdout) {
|
|
38
43
|
return super.generate({
|
|
39
44
|
options: args.options,
|
|
40
45
|
abortSignal: args.abortSignal,
|
|
41
46
|
...promptArgs,
|
|
47
|
+
...outputArgs,
|
|
42
48
|
timeout: args.timeout,
|
|
43
49
|
onStepFinish: args.onStepFinish,
|
|
44
50
|
});
|
|
@@ -47,6 +53,7 @@ export class OpenAIAgent extends ToolLoopAgent {
|
|
|
47
53
|
options: args.options,
|
|
48
54
|
abortSignal: args.abortSignal,
|
|
49
55
|
...promptArgs,
|
|
56
|
+
...outputArgs,
|
|
50
57
|
timeout: args.timeout,
|
|
51
58
|
onStepFinish: args.onStepFinish,
|
|
52
59
|
}).then((stream) => streamResultToGenerateResult(stream, args.onStdout));
|