@link-assistant/agent 0.16.3 → 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.3",
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,33 @@
1
+ /**
2
+ * CLI argument utilities for parsing process.argv directly
3
+ * These provide safeguards against yargs caching issues (#192)
4
+ */
5
+
6
+ /**
7
+ * Extract model argument directly from process.argv
8
+ * This is a safeguard against yargs caching issues (#192)
9
+ * @returns The model argument from CLI or null if not found
10
+ */
11
+ export function getModelFromProcessArgv(): string | null {
12
+ const args = process.argv;
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ // Handle --model=value format
16
+ if (arg.startsWith('--model=')) {
17
+ return arg.substring('--model='.length);
18
+ }
19
+ // Handle --model value format
20
+ if (arg === '--model' && i + 1 < args.length) {
21
+ return args[i + 1];
22
+ }
23
+ // Handle -m=value format
24
+ if (arg.startsWith('-m=')) {
25
+ return arg.substring('-m='.length);
26
+ }
27
+ // Handle -m value format (but not if it looks like another flag)
28
+ if (arg === '-m' && i + 1 < args.length && !args[i + 1].startsWith('-')) {
29
+ return args[i + 1];
30
+ }
31
+ }
32
+ return null;
33
+ }
@@ -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,6 +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 { parseModelConfig } from './cli/model-config.js';
8
9
  // Bus is used via createBusEventSubscription in event-handler.js
9
10
  import { Session } from './session/index.ts';
10
11
  import { SessionPrompt } from './session/prompt.ts';
@@ -134,110 +135,6 @@ function readStdinWithTimeout(timeout = null) {
134
135
  });
135
136
  }
136
137
 
137
- // outputStatus is now imported from './cli/output.ts'
138
- // It outputs to stdout for non-error messages, stderr for errors
139
-
140
- /**
141
- * Parse model configuration from argv
142
- * Supports both explicit provider/model format and short model names.
143
- *
144
- * Format examples:
145
- * - "kilo/glm-5-free" -> uses kilo provider with glm-5-free model (explicit)
146
- * - "opencode/kimi-k2.5-free" -> uses opencode provider (explicit)
147
- * - "glm-5-free" -> resolved to kilo provider (unique free model)
148
- * - "kimi-k2.5-free" -> resolved to opencode provider (shared model, opencode preferred)
149
- *
150
- * @param {object} argv - Command line arguments
151
- * @returns {object} - { providerID, modelID }
152
- */
153
- async function parseModelConfig(argv) {
154
- const modelArg = argv.model;
155
-
156
- let providerID;
157
- let modelID;
158
-
159
- // Check if model includes explicit provider prefix
160
- if (modelArg.includes('/')) {
161
- // Explicit provider/model format - respect user's choice
162
- const modelParts = modelArg.split('/');
163
- providerID = modelParts[0];
164
- modelID = modelParts.slice(1).join('/');
165
-
166
- // Validate that providerID and modelID are not empty
167
- if (!providerID || !modelID) {
168
- providerID = providerID || 'opencode';
169
- modelID = modelID || 'kimi-k2.5-free';
170
- }
171
-
172
- // Log raw and parsed values to help diagnose model routing issues (#171)
173
- Log.Default.info(() => ({
174
- message: 'using explicit provider/model',
175
- rawModel: modelArg,
176
- providerID,
177
- modelID,
178
- }));
179
- } else {
180
- // Short model name - resolve to appropriate provider
181
- // Import Provider to use parseModelWithResolution
182
- const { Provider } = await import('./provider/provider.ts');
183
- const resolved = await Provider.parseModelWithResolution(modelArg);
184
- providerID = resolved.providerID;
185
- modelID = resolved.modelID;
186
-
187
- Log.Default.info(() => ({
188
- message: 'resolved short model name',
189
- input: modelArg,
190
- providerID,
191
- modelID,
192
- }));
193
- }
194
-
195
- // Handle --use-existing-claude-oauth option
196
- // This reads OAuth credentials from ~/.claude/.credentials.json (Claude Code CLI)
197
- // For new authentication, use: agent auth login (select Anthropic > Claude Pro/Max)
198
- if (argv['use-existing-claude-oauth']) {
199
- // Import ClaudeOAuth to check for credentials from Claude Code CLI
200
- const { ClaudeOAuth } = await import('./auth/claude-oauth.ts');
201
- const creds = await ClaudeOAuth.getCredentials();
202
-
203
- if (!creds?.accessToken) {
204
- const compactJson = argv['compact-json'] === true;
205
- outputError(
206
- {
207
- errorType: 'AuthenticationError',
208
- message:
209
- '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)',
210
- },
211
- compactJson
212
- );
213
- process.exit(1);
214
- }
215
-
216
- // Set environment variable for the provider to use
217
- process.env.CLAUDE_CODE_OAUTH_TOKEN = creds.accessToken;
218
-
219
- // If user specified the default model (opencode/kimi-k2.5-free), switch to claude-oauth
220
- // If user explicitly specified kilo or another provider, warn but respect their choice
221
- if (providerID === 'opencode' && modelID === 'kimi-k2.5-free') {
222
- providerID = 'claude-oauth';
223
- modelID = 'claude-sonnet-4-5';
224
- } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
225
- // If user specified a different provider explicitly, warn them
226
- const compactJson = argv['compact-json'] === true;
227
- outputStatus(
228
- {
229
- type: 'warning',
230
- message: `--use-existing-claude-oauth is set but model uses provider "${providerID}". Using specified provider.`,
231
- },
232
- compactJson
233
- );
234
- // Don't override - respect user's explicit provider choice
235
- }
236
- }
237
-
238
- return { providerID, modelID };
239
- }
240
-
241
138
  /**
242
139
  * Read system message from files if specified
243
140
  * @param {object} argv - Command line arguments
@@ -320,7 +217,11 @@ async function runAgentMode(argv, request) {
320
217
  fn: async () => {
321
218
  // Parse model config inside Instance.provide context
322
219
  // This allows parseModelWithResolution to access the provider state
323
- const { providerID, modelID } = await parseModelConfig(argv);
220
+ const { providerID, modelID } = await parseModelConfig(
221
+ argv,
222
+ outputError,
223
+ outputStatus
224
+ );
324
225
 
325
226
  if (argv.server) {
326
227
  // SERVER MODE: Start server and communicate via HTTP
@@ -396,7 +297,11 @@ async function runContinuousAgentMode(argv) {
396
297
  fn: async () => {
397
298
  // Parse model config inside Instance.provide context
398
299
  // This allows parseModelWithResolution to access the provider state
399
- const { providerID, modelID } = await parseModelConfig(argv);
300
+ const { providerID, modelID } = await parseModelConfig(
301
+ argv,
302
+ outputError,
303
+ outputStatus
304
+ );
400
305
 
401
306
  if (argv.server) {
402
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++;