@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/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
- if (!this.opts.env?.KIMI_SHARE_DIR) {
170
- const defaultShareDir = process.env.KIMI_SHARE_DIR ?? join(homedir(), ".kimi");
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(defaultShareDir)) {
358
+ if (existsSync(sourceShareDir)) {
173
359
  for (const name of ["config.toml", "credentials", "device_id", "latest_version.txt"]) {
174
- const src = join(defaultShareDir, name);
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
  };
@@ -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
  };
@@ -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 {ExtendedGenerateArgs<CALL_OPTIONS, TOOLS>} args
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));