@link-assistant/agent 0.16.4 → 0.16.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.16.4",
3
+ "version": "0.16.5",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,147 @@
1
+ import { getModelFromProcessArgv } from './argv.ts';
2
+ import { Log } from '../util/log.ts';
3
+
4
+ /**
5
+ * Parse model config from argv. Supports "provider/model" or short "model" format.
6
+ * @param {object} argv - Parsed command line arguments
7
+ * @param {function} outputError - Function to output error messages
8
+ * @param {function} outputStatus - Function to output status messages
9
+ * @returns {Promise<{providerID: string, modelID: string}>}
10
+ */
11
+ export async function parseModelConfig(argv, outputError, outputStatus) {
12
+ // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196)
13
+ // This is critical because yargs under Bun may fail to parse --model correctly,
14
+ // returning the default value instead of the user's CLI argument.
15
+ const cliModelArg = getModelFromProcessArgv();
16
+ let modelArg = argv.model;
17
+
18
+ // ALWAYS prefer the CLI value over yargs when available (#196)
19
+ // The yargs default 'opencode/kimi-k2.5-free' can silently override user's --model argument
20
+ if (cliModelArg) {
21
+ if (cliModelArg !== modelArg) {
22
+ Log.Default.warn(() => ({
23
+ message: 'model argument mismatch detected - using CLI value',
24
+ yargsModel: modelArg,
25
+ cliModel: cliModelArg,
26
+ processArgv: process.argv.join(' '),
27
+ }));
28
+ }
29
+ // Always use CLI value when available, even if it matches yargs
30
+ // This ensures we use the actual CLI argument, not a cached/default yargs value
31
+ modelArg = cliModelArg;
32
+ }
33
+
34
+ let providerID;
35
+ let modelID;
36
+
37
+ // Check if model includes explicit provider prefix
38
+ if (modelArg.includes('/')) {
39
+ // Explicit provider/model format - respect user's choice
40
+ const modelParts = modelArg.split('/');
41
+ providerID = modelParts[0];
42
+ modelID = modelParts.slice(1).join('/');
43
+
44
+ // Validate that providerID and modelID are not empty
45
+ // Do NOT fall back to defaults - if the user provided an invalid format, fail clearly (#196)
46
+ if (!providerID || !modelID) {
47
+ throw new Error(
48
+ `Invalid model format: "${modelArg}". Expected "provider/model" format (e.g., "opencode/kimi-k2.5-free"). ` +
49
+ `Provider: "${providerID || '(empty)'}", Model: "${modelID || '(empty)'}".`
50
+ );
51
+ }
52
+
53
+ // Log raw and parsed values to help diagnose model routing issues (#171)
54
+ Log.Default.info(() => ({
55
+ message: 'using explicit provider/model',
56
+ rawModel: modelArg,
57
+ providerID,
58
+ modelID,
59
+ }));
60
+
61
+ // Validate that the model exists in the provider (#196)
62
+ // Without this check, a non-existent model silently proceeds and fails at API call time
63
+ // with confusing "reason: unknown" and zero tokens
64
+ try {
65
+ const { Provider } = await import('../provider/provider.ts');
66
+ const s = await Provider.state();
67
+ const provider = s.providers[providerID];
68
+ if (provider && !provider.info.models[modelID]) {
69
+ // Provider exists but model doesn't - warn and suggest alternatives
70
+ const availableModels = Object.keys(provider.info.models).slice(0, 5);
71
+ Log.Default.warn(() => ({
72
+ message:
73
+ 'model not found in provider - will attempt anyway (provider may support unlisted models)',
74
+ providerID,
75
+ modelID,
76
+ availableModels,
77
+ }));
78
+ }
79
+ } catch (validationError) {
80
+ // Don't fail on validation errors - the model may still work
81
+ // This is a best-effort check
82
+ Log.Default.info(() => ({
83
+ message: 'skipping model existence validation',
84
+ reason: validationError?.message,
85
+ }));
86
+ }
87
+ } else {
88
+ // Short model name - resolve to appropriate provider
89
+ // Import Provider to use parseModelWithResolution
90
+ const { Provider } = await import('../provider/provider.ts');
91
+ const resolved = await Provider.parseModelWithResolution(modelArg);
92
+ providerID = resolved.providerID;
93
+ modelID = resolved.modelID;
94
+
95
+ Log.Default.info(() => ({
96
+ message: 'resolved short model name',
97
+ input: modelArg,
98
+ providerID,
99
+ modelID,
100
+ }));
101
+ }
102
+
103
+ // Handle --use-existing-claude-oauth option
104
+ // This reads OAuth credentials from ~/.claude/.credentials.json (Claude Code CLI)
105
+ // For new authentication, use: agent auth login (select Anthropic > Claude Pro/Max)
106
+ if (argv['use-existing-claude-oauth']) {
107
+ // Import ClaudeOAuth to check for credentials from Claude Code CLI
108
+ const { ClaudeOAuth } = await import('../auth/claude-oauth.ts');
109
+ const creds = await ClaudeOAuth.getCredentials();
110
+
111
+ if (!creds?.accessToken) {
112
+ const compactJson = argv['compact-json'] === true;
113
+ outputError(
114
+ {
115
+ errorType: 'AuthenticationError',
116
+ message:
117
+ 'No Claude OAuth credentials found in ~/.claude/.credentials.json. Either authenticate with Claude Code CLI first, or use: agent auth login (select Anthropic > Claude Pro/Max)',
118
+ },
119
+ compactJson
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ // Set environment variable for the provider to use
125
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = creds.accessToken;
126
+
127
+ // If user specified the default model (opencode/kimi-k2.5-free), switch to claude-oauth
128
+ // If user explicitly specified kilo or another provider, warn but respect their choice
129
+ if (providerID === 'opencode' && modelID === 'kimi-k2.5-free') {
130
+ providerID = 'claude-oauth';
131
+ modelID = 'claude-sonnet-4-5';
132
+ } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
133
+ // If user specified a different provider explicitly, warn them
134
+ const compactJson = argv['compact-json'] === true;
135
+ outputStatus(
136
+ {
137
+ type: 'warning',
138
+ message: `--use-existing-claude-oauth is set but model uses provider "${providerID}". Using specified provider.`,
139
+ },
140
+ compactJson
141
+ );
142
+ // Don't override - respect user's explicit provider choice
143
+ }
144
+ }
145
+
146
+ return { providerID, modelID };
147
+ }
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ setProcessName('agent');
5
5
  import { Server } from './server/server.ts';
6
6
  import { Instance } from './project/instance.ts';
7
7
  import { Log } from './util/log.ts';
8
- import { getModelFromProcessArgv } from './cli/argv.ts';
8
+ import { parseModelConfig } from './cli/model-config.js';
9
9
  // Bus is used via createBusEventSubscription in event-handler.js
10
10
  import { Session } from './session/index.ts';
11
11
  import { SessionPrompt } from './session/prompt.ts';
@@ -135,106 +135,6 @@ function readStdinWithTimeout(timeout = null) {
135
135
  });
136
136
  }
137
137
 
138
- /** Parse model config from argv. Supports "provider/model" or short "model" format. */
139
- async function parseModelConfig(argv) {
140
- // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192)
141
- const cliModelArg = getModelFromProcessArgv();
142
- let modelArg = argv.model;
143
- if (cliModelArg && cliModelArg !== modelArg) {
144
- Log.Default.warn(() => ({
145
- message: 'model argument mismatch detected - using CLI value',
146
- yargsModel: modelArg,
147
- cliModel: cliModelArg,
148
- processArgv: process.argv.join(' '),
149
- }));
150
- modelArg = cliModelArg;
151
- }
152
-
153
- let providerID;
154
- let modelID;
155
-
156
- // Check if model includes explicit provider prefix
157
- if (modelArg.includes('/')) {
158
- // Explicit provider/model format - respect user's choice
159
- const modelParts = modelArg.split('/');
160
- providerID = modelParts[0];
161
- modelID = modelParts.slice(1).join('/');
162
-
163
- // Validate that providerID and modelID are not empty
164
- if (!providerID || !modelID) {
165
- providerID = providerID || 'opencode';
166
- modelID = modelID || 'kimi-k2.5-free';
167
- }
168
-
169
- // Log raw and parsed values to help diagnose model routing issues (#171)
170
- Log.Default.info(() => ({
171
- message: 'using explicit provider/model',
172
- rawModel: modelArg,
173
- providerID,
174
- modelID,
175
- }));
176
- } else {
177
- // Short model name - resolve to appropriate provider
178
- // Import Provider to use parseModelWithResolution
179
- const { Provider } = await import('./provider/provider.ts');
180
- const resolved = await Provider.parseModelWithResolution(modelArg);
181
- providerID = resolved.providerID;
182
- modelID = resolved.modelID;
183
-
184
- Log.Default.info(() => ({
185
- message: 'resolved short model name',
186
- input: modelArg,
187
- providerID,
188
- modelID,
189
- }));
190
- }
191
-
192
- // Handle --use-existing-claude-oauth option
193
- // This reads OAuth credentials from ~/.claude/.credentials.json (Claude Code CLI)
194
- // For new authentication, use: agent auth login (select Anthropic > Claude Pro/Max)
195
- if (argv['use-existing-claude-oauth']) {
196
- // Import ClaudeOAuth to check for credentials from Claude Code CLI
197
- const { ClaudeOAuth } = await import('./auth/claude-oauth.ts');
198
- const creds = await ClaudeOAuth.getCredentials();
199
-
200
- if (!creds?.accessToken) {
201
- const compactJson = argv['compact-json'] === true;
202
- outputError(
203
- {
204
- errorType: 'AuthenticationError',
205
- message:
206
- 'No Claude OAuth credentials found in ~/.claude/.credentials.json. Either authenticate with Claude Code CLI first, or use: agent auth login (select Anthropic > Claude Pro/Max)',
207
- },
208
- compactJson
209
- );
210
- process.exit(1);
211
- }
212
-
213
- // Set environment variable for the provider to use
214
- process.env.CLAUDE_CODE_OAUTH_TOKEN = creds.accessToken;
215
-
216
- // If user specified the default model (opencode/kimi-k2.5-free), switch to claude-oauth
217
- // If user explicitly specified kilo or another provider, warn but respect their choice
218
- if (providerID === 'opencode' && modelID === 'kimi-k2.5-free') {
219
- providerID = 'claude-oauth';
220
- modelID = 'claude-sonnet-4-5';
221
- } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
222
- // If user specified a different provider explicitly, warn them
223
- const compactJson = argv['compact-json'] === true;
224
- outputStatus(
225
- {
226
- type: 'warning',
227
- message: `--use-existing-claude-oauth is set but model uses provider "${providerID}". Using specified provider.`,
228
- },
229
- compactJson
230
- );
231
- // Don't override - respect user's explicit provider choice
232
- }
233
- }
234
-
235
- return { providerID, modelID };
236
- }
237
-
238
138
  /**
239
139
  * Read system message from files if specified
240
140
  * @param {object} argv - Command line arguments
@@ -317,7 +217,11 @@ async function runAgentMode(argv, request) {
317
217
  fn: async () => {
318
218
  // Parse model config inside Instance.provide context
319
219
  // This allows parseModelWithResolution to access the provider state
320
- const { providerID, modelID } = await parseModelConfig(argv);
220
+ const { providerID, modelID } = await parseModelConfig(
221
+ argv,
222
+ outputError,
223
+ outputStatus
224
+ );
321
225
 
322
226
  if (argv.server) {
323
227
  // SERVER MODE: Start server and communicate via HTTP
@@ -393,7 +297,11 @@ async function runContinuousAgentMode(argv) {
393
297
  fn: async () => {
394
298
  // Parse model config inside Instance.provide context
395
299
  // This allows parseModelWithResolution to access the provider state
396
- const { providerID, modelID } = await parseModelConfig(argv);
300
+ const { providerID, modelID } = await parseModelConfig(
301
+ argv,
302
+ outputError,
303
+ outputStatus
304
+ );
397
305
 
398
306
  if (argv.server) {
399
307
  // SERVER MODE: Start server and communicate via HTTP
@@ -1593,13 +1593,20 @@ export namespace Provider {
1593
1593
  * Parse a model string that may or may not include a provider prefix.
1594
1594
  * If no provider is specified, attempts to resolve the short model name to the appropriate provider.
1595
1595
  *
1596
+ * IMPORTANT: If the model cannot be resolved, this function throws an error instead of
1597
+ * silently falling back to a default. This ensures the user gets their requested model
1598
+ * or a clear error explaining why it's not available.
1599
+ *
1596
1600
  * Examples:
1597
1601
  * - "kilo/glm-5-free" -> { providerID: "kilo", modelID: "glm-5-free" }
1598
1602
  * - "glm-5-free" -> { providerID: "kilo", modelID: "glm-5-free" } (resolved)
1599
1603
  * - "kimi-k2.5-free" -> { providerID: "opencode", modelID: "kimi-k2.5-free" } (resolved)
1604
+ * - "nonexistent-model" -> throws ModelNotFoundError
1600
1605
  *
1601
1606
  * @param model - Model string with or without provider prefix
1602
1607
  * @returns Parsed provider ID and model ID
1608
+ * @throws ModelNotFoundError if the model cannot be found in any provider
1609
+ * @see https://github.com/link-assistant/agent/issues/194
1603
1610
  */
1604
1611
  export async function parseModelWithResolution(
1605
1612
  model: string
@@ -1616,15 +1623,36 @@ export namespace Provider {
1616
1623
  return resolved;
1617
1624
  }
1618
1625
 
1619
- // Unable to resolve - fall back to default behavior (opencode provider)
1620
- log.warn(() => ({
1621
- message: 'unable to resolve short model name, using opencode as default',
1622
- modelID: model,
1626
+ // Unable to resolve - fail with a clear error instead of silently falling back
1627
+ // This prevents the issue where user requests model X but gets model Y
1628
+ // See: https://github.com/link-assistant/agent/issues/194
1629
+ const s = await state();
1630
+ const availableModels: string[] = [];
1631
+
1632
+ // Collect some available models to suggest
1633
+ for (const [providerID, provider] of Object.entries(s.providers)) {
1634
+ for (const modelID of Object.keys(provider.info.models).slice(0, 3)) {
1635
+ availableModels.push(`${providerID}/${modelID}`);
1636
+ }
1637
+ if (availableModels.length >= 6) break;
1638
+ }
1639
+
1640
+ const suggestion =
1641
+ availableModels.length > 0
1642
+ ? `Model "${model}" not found in any provider. Available models include: ${availableModels.join(', ')}. Use --model provider/model-id format for explicit selection.`
1643
+ : `Model "${model}" not found. No providers are currently available. Check your API keys or authentication.`;
1644
+
1645
+ log.error(() => ({
1646
+ message: 'model not found - refusing to silently fallback',
1647
+ requestedModel: model,
1648
+ availableModels,
1623
1649
  }));
1624
- return {
1625
- providerID: 'opencode',
1650
+
1651
+ throw new ModelNotFoundError({
1652
+ providerID: 'unknown',
1626
1653
  modelID: model,
1627
- };
1654
+ suggestion,
1655
+ });
1628
1656
  }
1629
1657
 
1630
1658
  /**
@@ -273,6 +273,26 @@ export namespace SessionProcessor {
273
273
  }
274
274
  }
275
275
  }
276
+
277
+ // CRITICAL FIX for issue #194: Infer finish reason from tool calls
278
+ // If finishReason is still undefined but we have pending tool calls,
279
+ // the model intended to make tool calls - infer 'tool-calls' as the reason.
280
+ // This prevents premature loop exit when providers don't return finishReason.
281
+ // See: https://github.com/link-assistant/agent/issues/194
282
+ if (rawFinishReason === undefined) {
283
+ const pendingToolCallCount = Object.keys(toolcalls).length;
284
+ if (pendingToolCallCount > 0) {
285
+ log.info(() => ({
286
+ message:
287
+ 'inferred tool-calls finish reason from pending tool calls',
288
+ pendingToolCallCount,
289
+ providerID: input.providerID,
290
+ hint: 'Provider returned undefined finishReason but made tool calls',
291
+ }));
292
+ rawFinishReason = 'tool-calls';
293
+ }
294
+ }
295
+
276
296
  const finishReason = Session.toFinishReason(rawFinishReason);
277
297
  input.assistantMessage.finish = finishReason;
278
298
  input.assistantMessage.cost += usage.cost;
@@ -272,13 +272,70 @@ export namespace SessionPrompt {
272
272
  throw new Error(
273
273
  'No user message found in stream. This should never happen.'
274
274
  );
275
+
276
+ // Check if we should exit the agentic loop
277
+ // We exit when: the assistant has a finish reason, it's not 'tool-calls',
278
+ // and the assistant message is newer than the last user message
275
279
  if (
276
280
  lastAssistant?.finish &&
277
281
  lastAssistant.finish !== 'tool-calls' &&
278
282
  lastUser.id < lastAssistant.id
279
283
  ) {
280
- log.info(() => ({ message: 'exiting loop', sessionID }));
281
- break;
284
+ // SAFETY CHECK for issue #194: If finish reason is 'unknown', check for tool calls
285
+ // Some providers (e.g., Kimi K2.5 via OpenCode) return undefined finishReason
286
+ // which gets converted to 'unknown'. If there were tool calls made, we should
287
+ // continue the loop to execute them instead of prematurely exiting.
288
+ // See: https://github.com/link-assistant/agent/issues/194
289
+ if (lastAssistant.finish === 'unknown') {
290
+ // SAFETY CHECK for issue #196: Detect zero-token responses as provider failures
291
+ // When all tokens are 0 and finish reason is 'unknown', this indicates the provider
292
+ // returned an empty/error response (e.g., rate limit, model unavailable, API failure).
293
+ // Log a clear error message so the problem is visible in logs.
294
+ const tokens = lastAssistant.tokens;
295
+ if (
296
+ tokens.input === 0 &&
297
+ tokens.output === 0 &&
298
+ tokens.reasoning === 0
299
+ ) {
300
+ log.error(() => ({
301
+ message:
302
+ 'provider returned zero tokens with unknown finish reason - possible API failure',
303
+ sessionID,
304
+ finishReason: lastAssistant.finish,
305
+ tokens,
306
+ cost: lastAssistant.cost,
307
+ model: lastAssistant.model,
308
+ hint: 'This usually indicates the provider failed to process the request. Check provider status, model availability, and API keys.',
309
+ issue: 'https://github.com/link-assistant/agent/issues/196',
310
+ }));
311
+ break;
312
+ }
313
+
314
+ const lastAssistantParts = msgs.find(
315
+ (m) => m.info.id === lastAssistant.id
316
+ )?.parts;
317
+ const hasToolCalls = lastAssistantParts?.some(
318
+ (p) =>
319
+ p.type === 'tool' &&
320
+ (p.state.status === 'completed' || p.state.status === 'running')
321
+ );
322
+ if (hasToolCalls) {
323
+ log.info(() => ({
324
+ message:
325
+ 'continuing loop despite unknown finish reason - tool calls detected',
326
+ sessionID,
327
+ finishReason: lastAssistant.finish,
328
+ hint: 'Provider returned undefined finishReason but made tool calls',
329
+ }));
330
+ // Don't break - continue the loop to handle tool call results
331
+ } else {
332
+ log.info(() => ({ message: 'exiting loop', sessionID }));
333
+ break;
334
+ }
335
+ } else {
336
+ log.info(() => ({ message: 'exiting loop', sessionID }));
337
+ break;
338
+ }
282
339
  }
283
340
 
284
341
  step++;