@tangle-network/agent-interface 0.14.0 → 0.16.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.
@@ -29,6 +29,20 @@ export declare function harnessSupportsModel(harness: HarnessType, modelId: stri
29
29
  /** The harness to adopt for a model whose provider is vendor-locked (`anthropic` → `claude-code`,
30
30
  * `openai` → `codex`, `moonshot` → `kimi-code`); `null` when any router-backed harness will do. */
31
31
  export declare function preferredHarnessForModel(modelId: string): HarnessType | null;
32
+ /**
33
+ * Keep `modelId` when the harness can run it; otherwise return the harness's best compatible id from
34
+ * `candidateIds` (preferred patterns in order, highest version within a pattern). When nothing in the
35
+ * candidate list fits, the original id is returned unchanged so the caller sees the incompatibility
36
+ * instead of a silent wrong substitution. `candidateIds` are canonical ("provider/model") ids — the
37
+ * caller maps its own catalog shape down to ids, keeping this layer catalog-agnostic.
38
+ */
39
+ export declare function snapModelToHarness(harness: HarnessType, modelId: string, candidateIds: readonly string[]): string;
40
+ /**
41
+ * Keep the harness when it can run `modelId`; otherwise return the model's native harness
42
+ * (anthropic → claude-code, openai → codex, moonshot → kimi-code), falling back to the router-backed
43
+ * `opencode` for everything else.
44
+ */
45
+ export declare function snapHarnessToModel(harness: HarnessType, modelId: string): HarnessType;
32
46
  /** The reasoning efforts a harness can express, independent of model — `none` up to its ceiling. */
33
47
  export declare function harnessReasoningEfforts(harness: HarnessType): readonly ReasoningEffort[];
34
48
  /** What the caller knows about a model's own reasoning capability (from a model catalog). */
@@ -44,3 +58,9 @@ export interface ModelReasoningCapability {
44
58
  * lower ceiling caps the list there. Pass `model` from your catalog; omit it for the harness-only set.
45
59
  */
46
60
  export declare function reasoningEffortsFor(harness: HarnessType, model?: ModelReasoningCapability | null): readonly ReasoningEffort[];
61
+ /** Whether the harness's runner honors a per-turn MODEL override (vs. picking the model itself). */
62
+ export declare function harnessHonorsModel(harness: HarnessType): boolean;
63
+ /** Whether the harness's runner honors a reasoning-EFFORT override (vs. dropping it). */
64
+ export declare function harnessHonorsEffort(harness: HarnessType): boolean;
65
+ /** Whether the harness honors BOTH chat selectors — i.e. the model and effort pickers are live. */
66
+ export declare function harnessHonorsSelectors(harness: HarnessType): boolean;
@@ -29,10 +29,13 @@ export const reasoningLadder = [
29
29
  * Provider prefixes a harness is vendor-locked to (canonical-id prefix, e.g. `anthropic`, `openai`).
30
30
  * A harness with no entry is router-backed: it runs any model. Keyed by the BASE runner — aliases
31
31
  * (`claude`/`claudish`/`kimi`) resolve through `canonicalizeHarness` first.
32
+ *
33
+ * `nanoclaw` is deliberately absent despite the "claw" name: its runner routes every provider through
34
+ * the Tangle router (canonical model id straight to the gateway), so it is router-backed like
35
+ * `opencode` — not Anthropic-locked.
32
36
  */
33
37
  const harnessProviderLock = {
34
38
  "claude-code": ["anthropic"],
35
- nanoclaw: ["anthropic"],
36
39
  codex: ["openai"],
37
40
  "kimi-code": ["moonshot"],
38
41
  };
@@ -68,19 +71,69 @@ export function preferredHarnessForModel(modelId) {
68
71
  }
69
72
  return null;
70
73
  }
74
+ // ── Harness ↔ model snapping (catalog-aware) ─────────────────────────────────
75
+ /**
76
+ * Per-harness ranking patterns for {@link snapModelToHarness}, best first; within one pattern the
77
+ * highest version wins (numeric-aware). Only vendor-locked harnesses need an entry — a router-backed
78
+ * harness never snaps (it runs the model as-is). Keyed by the BASE runner (aliases canonicalized).
79
+ */
80
+ const harnessPreferredModelPatterns = {
81
+ "claude-code": [
82
+ /^anthropic\/claude-opus-[\d.-]+$/,
83
+ /^anthropic\/claude-sonnet-[\d.-]+$/,
84
+ /^anthropic\//,
85
+ ],
86
+ codex: [/^openai\/gpt-\d+(\.\d+)?$/, /^openai\/gpt/, /^openai\//],
87
+ "kimi-code": [/^moonshot\//],
88
+ };
89
+ const numericDesc = new Intl.Collator(undefined, {
90
+ numeric: true,
91
+ sensitivity: "base",
92
+ });
93
+ /**
94
+ * Keep `modelId` when the harness can run it; otherwise return the harness's best compatible id from
95
+ * `candidateIds` (preferred patterns in order, highest version within a pattern). When nothing in the
96
+ * candidate list fits, the original id is returned unchanged so the caller sees the incompatibility
97
+ * instead of a silent wrong substitution. `candidateIds` are canonical ("provider/model") ids — the
98
+ * caller maps its own catalog shape down to ids, keeping this layer catalog-agnostic.
99
+ */
100
+ export function snapModelToHarness(harness, modelId, candidateIds) {
101
+ if (harnessSupportsModel(harness, modelId))
102
+ return modelId;
103
+ const patterns = harnessPreferredModelPatterns[canonicalizeHarness(harness)] ?? [];
104
+ for (const pattern of patterns) {
105
+ const matches = candidateIds
106
+ .filter((id) => pattern.test(id))
107
+ .sort((a, b) => numericDesc.compare(b, a));
108
+ if (matches.length > 0)
109
+ return matches[0];
110
+ }
111
+ return candidateIds.find((id) => harnessSupportsModel(harness, id)) ?? modelId;
112
+ }
113
+ /**
114
+ * Keep the harness when it can run `modelId`; otherwise return the model's native harness
115
+ * (anthropic → claude-code, openai → codex, moonshot → kimi-code), falling back to the router-backed
116
+ * `opencode` for everything else.
117
+ */
118
+ export function snapHarnessToModel(harness, modelId) {
119
+ if (harnessSupportsModel(harness, modelId))
120
+ return harness;
121
+ return preferredHarnessForModel(modelId) ?? "opencode";
122
+ }
71
123
  // ── Reasoning-effort support ──────────────────────────────────────────────────
72
124
  /**
73
125
  * The highest reasoning effort a harness's runtime can express (its native clamp ceiling). Grounded
74
126
  * in cli-bridge: codex's `model_reasoning_effort` caps at `high` (xhigh/ultracode clamp down); kimi's
75
127
  * `--thinking` is binary, so `high` is its "on"; claude-code carries the full range; `cli-base` has
76
- * no agent and thus no thinking. Router/model-driven harnesses default to the full range.
128
+ * no agent and thus no thinking; `nanoclaw`'s runner sends no thinking flag, so it expresses only
129
+ * `none`. Router/model-driven harnesses default to the full range.
77
130
  */
78
131
  const harnessReasoningCeiling = {
79
132
  "cli-base": "none",
80
133
  codex: "high",
81
134
  "kimi-code": "high",
82
135
  "claude-code": "ultracode",
83
- nanoclaw: "ultracode",
136
+ nanoclaw: "none",
84
137
  };
85
138
  /** The reasoning efforts a harness can express, independent of model — `none` up to its ceiling. */
86
139
  export function harnessReasoningEfforts(harness) {
@@ -103,3 +156,40 @@ export function reasoningEffortsFor(harness, model) {
103
156
  }
104
157
  return efforts;
105
158
  }
159
+ // ── Per-turn selector support (does the harness honor the chat pickers?) ──────
160
+ /**
161
+ * Harnesses whose runner DROPS a per-turn selector — grounded in the cli-bridge adapter audit, NOT a
162
+ * guess. Most harnesses honor both selectors, so only the exceptions are listed; a harness absent from
163
+ * a set honors that selector. Keyed by the BASE runner (aliases canonicalized).
164
+ *
165
+ * - model dropped: `amp` (own agent picks the model), `openclaw` (dispatcher routes by its own
166
+ * config), `nanoclaw` (socket-bridge runner is config/env-driven).
167
+ * - effort dropped: `amp` and `factory-droids`/`hermes`/`nanoclaw` (no thinking flag is plumbed to
168
+ * the underlying CLI).
169
+ *
170
+ * This is distinct from {@link reasoningEffortsFor} (which levels a harness can EXPRESS): a picker uses
171
+ * these to trim or mark harnesses up front, so a user's model/effort choice is never silently ignored.
172
+ */
173
+ const harnessIgnoresModel = new Set([
174
+ "amp",
175
+ "openclaw",
176
+ "nanoclaw",
177
+ ]);
178
+ const harnessIgnoresEffort = new Set([
179
+ "amp",
180
+ "factory-droids",
181
+ "hermes",
182
+ "nanoclaw",
183
+ ]);
184
+ /** Whether the harness's runner honors a per-turn MODEL override (vs. picking the model itself). */
185
+ export function harnessHonorsModel(harness) {
186
+ return !harnessIgnoresModel.has(canonicalizeHarness(harness));
187
+ }
188
+ /** Whether the harness's runner honors a reasoning-EFFORT override (vs. dropping it). */
189
+ export function harnessHonorsEffort(harness) {
190
+ return !harnessIgnoresEffort.has(canonicalizeHarness(harness));
191
+ }
192
+ /** Whether the harness honors BOTH chat selectors — i.e. the model and effort pickers are live. */
193
+ export function harnessHonorsSelectors(harness) {
194
+ return harnessHonorsModel(harness) && harnessHonorsEffort(harness);
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-interface",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -36,11 +36,14 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "25.6.0",
39
- "typescript": "^6.0.3"
39
+ "typescript": "^6.0.3",
40
+ "vitest": "^4.1.5"
40
41
  },
41
42
  "scripts": {
42
43
  "build": "tsc -p tsconfig.json",
43
44
  "check-types": "tsc --noEmit",
44
- "clean": "rm -rf dist"
45
+ "clean": "rm -rf dist",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest"
45
48
  }
46
49
  }