@link-assistant/agent 0.26.5 → 0.26.6

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/README.md CHANGED
@@ -347,6 +347,8 @@ Commands:
347
347
 
348
348
  See [docs/stdin-mode.md](../docs/stdin-mode.md) for comprehensive stdin mode documentation.
349
349
 
350
+ Hosts that embed the CLI supply configuration through `LINK_ASSISTANT_AGENT_CONFIG_DIR`, `LINK_ASSISTANT_AGENT_CONFIG` or `LINK_ASSISTANT_AGENT_CONFIG_CONTENT` — never by relocating `XDG_CONFIG_HOME`, which would also hide `gh`/`git` credentials from the agent's tool calls. See [docs/host-integration.md](../docs/host-integration.md).
351
+
350
352
  ### JSON Output Standards
351
353
 
352
354
  The agent supports two JSON output format standards via the `--json-standard` option:
@@ -773,6 +775,7 @@ For full documentation, see the [main README](../README.md) in the repository ro
773
775
  - [Tools Reference](../TOOLS.md)
774
776
  - [Usage Examples](../EXAMPLES.md)
775
777
  - [Testing Guide](../TESTING.md)
778
+ - [Host Integration](../docs/host-integration.md) — supplying config via `LINK_ASSISTANT_AGENT_CONFIG_DIR`, `LINK_ASSISTANT_AGENT_CONFIG` and `LINK_ASSISTANT_AGENT_CONFIG_CONTENT`
776
779
 
777
780
  ## License
778
781
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.26.5",
3
+ "version": "0.26.6",
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",
@@ -358,6 +358,52 @@ async function resolveCompactionModelEntry(
358
358
  };
359
359
  }
360
360
 
361
+ /**
362
+ * Resolve a built-in cascade entry against the run's own provider only.
363
+ *
364
+ * The built-in cascade is narrowed to the configured provider anyway (#307),
365
+ * so resolving its short names across the whole provider registry only to
366
+ * drop the result afterwards was wasted work — and it logged records such as
367
+ * `{"providerID":"opencode","message":"resolved short model name"}` that a
368
+ * host reads as the run using OpenCode (#313). Only the base provider's own
369
+ * catalog is consulted here; any other provider is never looked up.
370
+ *
371
+ * @returns {Promise<{providerID: string, modelID: string, useSameModel: boolean} | null>}
372
+ * the entry, or `null` when the base provider does not serve it
373
+ * @see https://github.com/link-assistant/agent/issues/313
374
+ */
375
+ async function resolveEntryWithinProvider(
376
+ modelArg,
377
+ baseProviderID,
378
+ baseModelID
379
+ ) {
380
+ if (modelArg.toLowerCase() === 'same') {
381
+ return {
382
+ providerID: baseProviderID,
383
+ modelID: baseModelID,
384
+ useSameModel: true,
385
+ };
386
+ }
387
+
388
+ if (modelArg.includes('/')) {
389
+ const parts = modelArg.split('/');
390
+ if (parts[0] !== baseProviderID) {
391
+ return null;
392
+ }
393
+ return {
394
+ providerID: parts[0],
395
+ modelID: parts.slice(1).join('/'),
396
+ useSameModel: false,
397
+ };
398
+ }
399
+
400
+ const { Provider } = await import('../provider/provider.ts');
401
+ if (!(await Provider.hasModel(baseProviderID, modelArg))) {
402
+ return null;
403
+ }
404
+ return { providerID: baseProviderID, modelID: modelArg, useSameModel: false };
405
+ }
406
+
361
407
  /**
362
408
  * Narrow a built-in compaction default to the provider the run was pointed at.
363
409
  *
@@ -376,19 +422,17 @@ async function resolveCompactionModelEntry(
376
422
  * provider keeps the shipped cascade, whose extra entries are the rate-limit
377
423
  * fallbacks it was written for.
378
424
  *
379
- * @param {{providerID: string, modelID: string, useSameModel: boolean}[]} entries
425
+ * @param {{providerID: string, modelID: string, useSameModel: boolean}[]} kept
426
+ * Entries served by the base provider
427
+ * @param {string[]} dropped Default entries, as written, that were left out
380
428
  * @param {string} baseProviderID Provider of the model that runs the turns
381
429
  * @param {string} baseModelID Model that runs the turns
382
430
  * @returns {{providerID: string, modelID: string, useSameModel: boolean}[]}
383
431
  * @see https://github.com/link-assistant/agent/issues/307
384
432
  */
385
- function inheritBaseProvider(entries, baseProviderID, baseModelID) {
386
- const kept = entries.filter(
387
- (entry) => entry.useSameModel || entry.providerID === baseProviderID
388
- );
389
- const dropped = entries.filter((entry) => !kept.includes(entry));
433
+ function inheritBaseProvider(kept, dropped, baseProviderID, baseModelID) {
390
434
  if (dropped.length === 0) {
391
- return entries;
435
+ return kept;
392
436
  }
393
437
 
394
438
  const inherited =
@@ -407,7 +451,9 @@ function inheritBaseProvider(entries, baseProviderID, baseModelID) {
407
451
  hint: 'Secondary calls (compaction, session summary) inherit --model unless a compaction model is named explicitly',
408
452
  baseProviderID,
409
453
  baseModelID,
410
- dropped: dropped.map((entry) => `${entry.providerID}/${entry.modelID}`),
454
+ // The names as written in the default: they were never resolved, so they
455
+ // say nothing about which provider would have served them (#313).
456
+ dropped,
411
457
  kept: inherited.map((entry) =>
412
458
  entry.useSameModel ? 'same' : `${entry.providerID}/${entry.modelID}`
413
459
  ),
@@ -459,9 +505,30 @@ async function parseCompactionModelConfig(
459
505
  const modelNames = parseLinksNotationSequence(compactionModelsArg);
460
506
 
461
507
  if (modelNames.length > 0) {
462
- // Resolve each model in the cascade
508
+ // A cascade nobody asked for must not reach across providers (#307), and
509
+ // is not even resolved against other providers (#313).
510
+ const builtInCascade =
511
+ compactionModelsSource === 'default' &&
512
+ getDefaultCompactionModelsSource(defaultOptions) === 'default';
513
+ const usingBuiltInCascade =
514
+ builtInCascade && baseProviderID !== DEFAULT_PROVIDER_ID;
515
+
463
516
  const compactionModels = [];
517
+ const unavailable = [];
464
518
  for (const name of modelNames) {
519
+ if (usingBuiltInCascade) {
520
+ const entry = await resolveEntryWithinProvider(
521
+ name,
522
+ baseProviderID,
523
+ baseModelID
524
+ );
525
+ if (entry) {
526
+ compactionModels.push(entry);
527
+ } else {
528
+ unavailable.push(name);
529
+ }
530
+ continue;
531
+ }
465
532
  try {
466
533
  const resolved = await resolveCompactionModelEntry(
467
534
  name,
@@ -474,6 +541,12 @@ async function parseCompactionModelConfig(
474
541
  useSameModel: resolved.useSameModel,
475
542
  });
476
543
  } catch (err) {
544
+ // A model nobody asked for is not an error worth a record of its
545
+ // own (#313); the summary below lists it.
546
+ if (builtInCascade) {
547
+ unavailable.push(name);
548
+ continue;
549
+ }
477
550
  const logSkip =
478
551
  compactionModelsSource === 'default'
479
552
  ? Log.Default.debug
@@ -486,13 +559,13 @@ async function parseCompactionModelConfig(
486
559
  }
487
560
  }
488
561
 
489
- // A cascade nobody asked for must not reach across providers (#307).
490
- const usingBuiltInCascade =
491
- compactionModelsSource === 'default' &&
492
- getDefaultCompactionModelsSource(defaultOptions) === 'default' &&
493
- baseProviderID !== DEFAULT_PROVIDER_ID;
494
562
  const cascade = usingBuiltInCascade
495
- ? inheritBaseProvider(compactionModels, baseProviderID, baseModelID)
563
+ ? inheritBaseProvider(
564
+ compactionModels,
565
+ unavailable,
566
+ baseProviderID,
567
+ baseModelID
568
+ )
496
569
  : compactionModels;
497
570
 
498
571
  Log.Default.info(() => ({
@@ -501,6 +574,9 @@ async function parseCompactionModelConfig(
501
574
  m.useSameModel ? 'same' : `${m.providerID}/${m.modelID}`
502
575
  ),
503
576
  source: compactionModelsSource,
577
+ ...(unavailable.length > 0 && !usingBuiltInCascade
578
+ ? { unavailable }
579
+ : {}),
504
580
  }));
505
581
 
506
582
  // Use the first model as the primary compaction model (for backward compatibility)
@@ -543,9 +619,17 @@ async function parseCompactionModelConfig(
543
619
  compactionModelSource === 'default' &&
544
620
  getDefaultCompactionModelSource(defaultOptions) === 'default' &&
545
621
  baseProviderID !== DEFAULT_PROVIDER_ID;
546
- const [entry] = usingBuiltInCompactionModel
547
- ? inheritBaseProvider([resolved], baseProviderID, baseModelID)
548
- : [resolved];
622
+ const [entry] =
623
+ usingBuiltInCompactionModel &&
624
+ !resolved.useSameModel &&
625
+ resolved.providerID !== baseProviderID
626
+ ? inheritBaseProvider(
627
+ [],
628
+ [compactionModelArg],
629
+ baseProviderID,
630
+ baseModelID
631
+ )
632
+ : [resolved];
549
633
 
550
634
  Log.Default.info(() => ({
551
635
  message: 'using single compaction model',
@@ -0,0 +1,62 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Host integration contract: how an embedding host hands the CLI its config.
6
+ *
7
+ * A host supplies configuration through the CLI's own variables:
8
+ *
9
+ * LINK_ASSISTANT_AGENT_CONFIG_DIR a directory holding opencode.json(c)
10
+ * LINK_ASSISTANT_AGENT_CONFIG a single config file
11
+ * LINK_ASSISTANT_AGENT_CONFIG_CONTENT the config itself, as JSON
12
+ *
13
+ * Relocating `XDG_CONFIG_HOME` to feed the CLI a generated config also moves
14
+ * the config home of every tool the agent runs — `gh` then finds no
15
+ * `hosts.yml` and the session loses GitHub authentication (#314). This module
16
+ * spots that setup so the CLI can point the host at the variables above.
17
+ *
18
+ * @see https://github.com/link-assistant/agent/issues/314
19
+ * @see ../../../docs/host-integration.md
20
+ */
21
+ export namespace HostConfigHint {
22
+ export const CONFIG_ENV_VARS = [
23
+ 'LINK_ASSISTANT_AGENT_CONFIG_DIR',
24
+ 'LINK_ASSISTANT_AGENT_CONFIG',
25
+ 'LINK_ASSISTANT_AGENT_CONFIG_CONTENT',
26
+ ] as const;
27
+
28
+ export interface Hint {
29
+ message: string;
30
+ xdgConfigHome: string;
31
+ realConfigHome: string;
32
+ hint: string;
33
+ }
34
+
35
+ /**
36
+ * Return a hint when `XDG_CONFIG_HOME` points away from the user's real
37
+ * config home and none of the CLI's own config variables is set — the
38
+ * signature of a host relocating XDG to deliver config. Pure, so it can be
39
+ * tested with any environment.
40
+ */
41
+ export function detect(
42
+ env: Record<string, string | undefined> = process.env,
43
+ homedir: string = os.homedir()
44
+ ): Hint | undefined {
45
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
46
+ if (!xdgConfigHome) return undefined;
47
+
48
+ const realConfigHome = path.join(homedir, '.config');
49
+ if (path.resolve(xdgConfigHome) === path.resolve(realConfigHome)) {
50
+ return undefined;
51
+ }
52
+
53
+ if (CONFIG_ENV_VARS.some((name) => env[name]?.trim())) return undefined;
54
+
55
+ return {
56
+ message: 'XDG_CONFIG_HOME is relocated',
57
+ xdgConfigHome,
58
+ realConfigHome,
59
+ hint: `To supply agent config, set ${CONFIG_ENV_VARS.join(', ')} instead of XDG_CONFIG_HOME: tools the agent runs (gh, git) read their config and credentials from XDG_CONFIG_HOME too`,
60
+ };
61
+ }
62
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  getConfigSnapshot,
8
8
  } from './config/config.ts';
9
9
  import { setProcessName } from './cli/process-name.ts';
10
+ import { HostConfigHint } from './config/host-config-hint.ts';
10
11
  setProcessName('agent');
11
12
  import { Server } from './server/server.ts';
12
13
  import { Instance } from './project/instance.ts';
@@ -909,6 +910,13 @@ async function main() {
909
910
  config: getConfigSnapshot(),
910
911
  }));
911
912
 
913
+ // A host that relocates XDG_CONFIG_HOME to deliver agent config also
914
+ // hides gh/git credentials from the agent's tool calls (#314).
915
+ const hostConfigHint = HostConfigHint.detect();
916
+ if (hostConfigHint) {
917
+ Log.Default.warn(() => hostConfigHint);
918
+ }
919
+
912
920
  // Global fetch monkey-patch for verbose HTTP logging (#221).
913
921
  if (!globalThis.__agentVerboseFetchInstalled) {
914
922
  globalThis.fetch = createVerboseFetch(globalThis.fetch, {
@@ -38,6 +38,14 @@ import { createGroq } from '@ai-sdk/groq';
38
38
 
39
39
  export namespace Provider {
40
40
  const log = Log.create({ service: 'provider' });
41
+ /**
42
+ * Catalog lookups — which providers are installed, which provider a short
43
+ * model name would map to — describe candidates, not the model a run uses.
44
+ * They are logged under their own service and never carry `providerID` /
45
+ * `modelID` fields, which stream consumers read as the run's model identity
46
+ * (#313). The active model is attested by the `model_resolved` event.
47
+ */
48
+ const registryLog = Log.create({ service: 'provider-registry' });
41
49
 
42
50
  /**
43
51
  * Bundled providers - these are pre-installed and imported statically
@@ -1235,8 +1243,11 @@ export namespace Provider {
1235
1243
  delete providers[providerID];
1236
1244
  continue;
1237
1245
  }
1238
- log.info(() => ({ message: 'found', providerID }));
1239
1246
  }
1247
+ registryLog.info(() => ({
1248
+ message: 'providers available',
1249
+ available: Object.keys(providers),
1250
+ }));
1240
1251
 
1241
1252
  return {
1242
1253
  models,
@@ -1696,6 +1707,15 @@ export namespace Provider {
1696
1707
  return state().then((s) => s.providers[providerID]);
1697
1708
  }
1698
1709
 
1710
+ /**
1711
+ * Whether a provider's catalog lists a model. Looks at that one provider
1712
+ * only and logs nothing (#313).
1713
+ */
1714
+ export async function hasModel(providerID: string, modelID: string) {
1715
+ const provider = await getProvider(providerID);
1716
+ return Boolean(provider?.info.models[modelID]);
1717
+ }
1718
+
1699
1719
  async function getLiveModelInfo(providerID: string, modelID: string) {
1700
1720
  if (providerID !== 'opencode') return undefined;
1701
1721
  return OpenCodeZen.getLiveFreeModelInfo(modelID);
@@ -2084,9 +2104,10 @@ export namespace Provider {
2084
2104
  if (kiloUniqueModels.includes(modelID)) {
2085
2105
  const kiloProvider = s.providers['kilo'];
2086
2106
  if (kiloProvider && kiloProvider.info.models[modelID]) {
2087
- log.info(() => ({
2107
+ registryLog.info(() => ({
2088
2108
  message: 'resolved short model name to kilo (unique)',
2089
- modelID,
2109
+ name: modelID,
2110
+ candidate: `kilo/${modelID}`,
2090
2111
  }));
2091
2112
  return { providerID: 'kilo', modelID };
2092
2113
  }
@@ -2095,9 +2116,10 @@ export namespace Provider {
2095
2116
  if (modelID === 'formal-ai') {
2096
2117
  const formalAiProvider = s.providers['formal-ai'];
2097
2118
  if (formalAiProvider && formalAiProvider.info.models[modelID]) {
2098
- log.info(() => ({
2119
+ registryLog.info(() => ({
2099
2120
  message: 'resolved short model name to formal-ai (canonical)',
2100
- modelID,
2121
+ name: modelID,
2122
+ candidate: `formal-ai/${modelID}`,
2101
2123
  }));
2102
2124
  return { providerID: 'formal-ai', modelID };
2103
2125
  }
@@ -2117,10 +2139,10 @@ export namespace Provider {
2117
2139
 
2118
2140
  if (matchingProviders.length === 1) {
2119
2141
  const providerID = matchingProviders[0];
2120
- log.info(() => ({
2142
+ registryLog.info(() => ({
2121
2143
  message: 'resolved short model name (single match)',
2122
- modelID,
2123
- providerID,
2144
+ name: modelID,
2145
+ candidate: `${providerID}/${modelID}`,
2124
2146
  }));
2125
2147
  return { providerID, modelID };
2126
2148
  }
@@ -2128,9 +2150,10 @@ export namespace Provider {
2128
2150
  // Multiple providers have this model - prefer OpenCode for shared free models
2129
2151
  // This follows the convention that opencode is the primary free provider
2130
2152
  if (matchingProviders.includes('opencode')) {
2131
- log.info(() => ({
2153
+ registryLog.info(() => ({
2132
2154
  message: 'resolved short model name to opencode (multiple providers)',
2133
- modelID,
2155
+ name: modelID,
2156
+ candidate: `opencode/${modelID}`,
2134
2157
  availableProviders: matchingProviders,
2135
2158
  }));
2136
2159
  return { providerID: 'opencode', modelID };
@@ -2138,10 +2161,10 @@ export namespace Provider {
2138
2161
 
2139
2162
  // Fallback to first matching provider
2140
2163
  const providerID = matchingProviders[0];
2141
- log.info(() => ({
2164
+ registryLog.info(() => ({
2142
2165
  message: 'resolved short model name (fallback)',
2143
- modelID,
2144
- providerID,
2166
+ name: modelID,
2167
+ candidate: `${providerID}/${modelID}`,
2145
2168
  availableProviders: matchingProviders,
2146
2169
  }));
2147
2170
  return { providerID, modelID };