@mlx-node/agent 0.0.12 → 0.0.15
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/dist/catalog.d.ts +10 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +11 -2
- package/dist/delegate.d.ts +29 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +106 -0
- package/dist/extensions/delegation.d.ts +15 -0
- package/dist/extensions/delegation.d.ts.map +1 -0
- package/dist/extensions/delegation.js +93 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +16 -0
- package/dist/provider/chat-config.d.ts +6 -5
- package/dist/provider/chat-config.d.ts.map +1 -1
- package/dist/provider/chat-config.js +21 -7
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +8 -1
- package/dist/provider/model-host.d.ts +1 -1
- package/dist/provider/model-host.d.ts.map +1 -1
- package/dist/provider/model-host.js +25 -7
- package/dist/provider/models.d.ts +3 -14
- package/dist/provider/models.d.ts.map +1 -1
- package/dist/provider/models.js +17 -239
- package/dist/provider/stream-adapter.d.ts +2 -2
- package/dist/provider/stream-adapter.d.ts.map +1 -1
- package/dist/provider/stream-adapter.js +8 -5
- package/dist/run-agent.d.ts +4 -0
- package/dist/run-agent.d.ts.map +1 -1
- package/dist/run-agent.js +8 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -5
- package/src/catalog.ts +194 -0
- package/src/cold-tier.ts +152 -0
- package/src/delegate.ts +136 -0
- package/src/extensions/approval-detail.ts +57 -0
- package/src/extensions/delegation.ts +109 -0
- package/src/extensions/local-image-input.ts +132 -0
- package/src/extensions/permission-gate.ts +347 -0
- package/src/extensions/subagent.ts +743 -0
- package/src/extensions/terminal-title.ts +53 -0
- package/src/extensions/trace-notice.ts +37 -0
- package/src/index.ts +23 -0
- package/src/paths.ts +36 -0
- package/src/provider/chat-config.ts +132 -0
- package/src/provider/convert-messages.ts +273 -0
- package/src/provider/error-coercion.ts +36 -0
- package/src/provider/events.ts +341 -0
- package/src/provider/index.ts +255 -0
- package/src/provider/metrics-trace.ts +380 -0
- package/src/provider/mlx-identity.ts +16 -0
- package/src/provider/model-host.ts +276 -0
- package/src/provider/model-registry-filter.ts +336 -0
- package/src/provider/models.ts +48 -0
- package/src/provider/performance-status.ts +112 -0
- package/src/provider/reasoning-tag-buffer.ts +67 -0
- package/src/provider/stream-adapter.ts +515 -0
- package/src/provider/tool-call-buffer.ts +82 -0
- package/src/provider/warm-reuse.ts +125 -0
- package/src/run-agent.ts +178 -0
- package/src/types.ts +10 -0
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated model catalog for `mlx agent`.
|
|
3
|
+
*
|
|
4
|
+
* The first-run download wizard offers `visibleCatalog()` and feeds the chosen
|
|
5
|
+
* entry's `catalogRepo()` to `mlx download model`. Slugs are verified against the Brooooooklyn
|
|
6
|
+
* HF account — use them verbatim.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { QWEN38_DFLASH2 } from '@mlx-node/lm/draft-companion';
|
|
10
|
+
|
|
11
|
+
export interface CatalogEntry {
|
|
12
|
+
/** Wizard display name. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** HF slug for `mlx download model` on Apple Silicon — the MXFP4 build. */
|
|
15
|
+
hfRepo: string;
|
|
16
|
+
/**
|
|
17
|
+
* HF slug on Linux + NVIDIA CUDA — the NVFP4 build.
|
|
18
|
+
*
|
|
19
|
+
* Absent means the entry has no CUDA-specific build and {@link hfRepo}
|
|
20
|
+
* serves both. Resolve with {@link catalogRepo}, never by reading the field.
|
|
21
|
+
*/
|
|
22
|
+
hfRepoCuda?: string;
|
|
23
|
+
/** Approximate download size in GB, for display. */
|
|
24
|
+
sizeGb: number;
|
|
25
|
+
/** Optional companion, downloaded separately and never offered as a chat model. */
|
|
26
|
+
draft?: { label: string; hfRepo: string; sizeGb: number };
|
|
27
|
+
/** One line for the wizard. */
|
|
28
|
+
description: string;
|
|
29
|
+
/** Exactly one entry carries this. */
|
|
30
|
+
isDefault?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The repo is not published yet, so no UI may offer it as a download.
|
|
33
|
+
*
|
|
34
|
+
* Two consumers honour this: the agent wizard via {@link visibleCatalog},
|
|
35
|
+
* and the dashboard Models page, which filters `!item.hidden` before
|
|
36
|
+
* rendering cards (`packages/dashboard/ui/src/pages/models.tsx`).
|
|
37
|
+
* `catalogWithState()` deliberately keeps hidden entries so the UI, not the
|
|
38
|
+
* dashboard core, decides.
|
|
39
|
+
*
|
|
40
|
+
* Also honoured by the download allowlist: `DownloadManager.start`
|
|
41
|
+
* (`packages/dashboard/src/download.ts`) refuses a hidden repo up front
|
|
42
|
+
* rather than allocating a job that fails mid-download with a 401 from
|
|
43
|
+
* Hugging Face. No UI reaches that path for a hidden entry, but a direct
|
|
44
|
+
* API call does.
|
|
45
|
+
*/
|
|
46
|
+
hidden?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const MODEL_CATALOG: readonly CatalogEntry[] = [
|
|
50
|
+
{
|
|
51
|
+
label: 'Qwen3.8-27B',
|
|
52
|
+
hfRepo: 'Brooooooklyn/Qwen3.8-27B-MXFP4-mlx',
|
|
53
|
+
hfRepoCuda: 'Brooooooklyn/Qwen3.8-27B-NVFP4-mlx',
|
|
54
|
+
sizeGb: 23.3,
|
|
55
|
+
description: 'Best tool use — recommended default',
|
|
56
|
+
isDefault: true,
|
|
57
|
+
draft: QWEN38_DFLASH2,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: 'Qwen-AgentWorld-35B-A3B',
|
|
61
|
+
hfRepo: 'Brooooooklyn/Qwen-AgentWorld-35B-A3B-mxfp4-mlx',
|
|
62
|
+
hfRepoCuda: 'Brooooooklyn/Qwen-AgentWorld-35B-A3B-nvfp4-mlx',
|
|
63
|
+
sizeGb: 23.3,
|
|
64
|
+
description: 'Agent-tuned MoE, fast decode',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
label: 'Gemma-4-26B-A4B',
|
|
68
|
+
hfRepo: 'Brooooooklyn/Gemma-4-26B-A4B-Unsloth-MXFP4-mlx',
|
|
69
|
+
hfRepoCuda: 'Brooooooklyn/Gemma-4-26B-A4B-Unsloth-NVFP4-mlx',
|
|
70
|
+
sizeGb: 16.2,
|
|
71
|
+
description: 'MoE, fast decode',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
// Produced + validated locally as mxfp4 (MLP) + mxfp8 (attention) via
|
|
75
|
+
// `mlx convert --q-recipe nvidia` on gemma-4-12b-it (coherent + tool
|
|
76
|
+
// calling through `mlx agent`). Provisional slug — the user finalizes it
|
|
77
|
+
// on HF upload; entry stays hidden until the repo exists.
|
|
78
|
+
label: 'Gemma-4-12B',
|
|
79
|
+
hfRepo: 'Brooooooklyn/Gemma-4-12B-IT-mxfp-mlx',
|
|
80
|
+
sizeGb: 8.6,
|
|
81
|
+
description: 'Compact (mxfp4 MLP + mxfp8 attention), fits smaller machines',
|
|
82
|
+
hidden: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// NOT DOWNLOADABLE. This repo does not exist: the slug below is a
|
|
86
|
+
// placeholder the user has not uploaded to, and Hugging Face answers 401
|
|
87
|
+
// for it. Nothing may offer it as an install until the upload happens and
|
|
88
|
+
// `hidden` is dropped — and do not guess a substitute slug, because a
|
|
89
|
+
// wrong-but-live repo would install the wrong weights silently.
|
|
90
|
+
//
|
|
91
|
+
// The only route to this model today is local conversion from NVIDIA's
|
|
92
|
+
// modelopt checkpoint:
|
|
93
|
+
// mlx convert -m nemotron_h \
|
|
94
|
+
// -i <nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4> \
|
|
95
|
+
// -o <models>/nemotron-3.5-lightning-30b-a3b-nvfp4-mlx
|
|
96
|
+
// (NVFP4 preserved byte-for-byte; the FP8 Mamba-2 projections are
|
|
97
|
+
// re-quantized. See docs/cli.md "modelopt NVFP4 ingest".)
|
|
98
|
+
//
|
|
99
|
+
// The entry is kept — rather than deleted — so the wizard, the dashboard
|
|
100
|
+
// catalog state, and `catalogSlug()` recognize a locally converted
|
|
101
|
+
// checkpoint sitting at the canonical slug.
|
|
102
|
+
label: 'Nemotron-3.5-Lightning-30B-A3B',
|
|
103
|
+
hfRepo: 'Brooooooklyn/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-nvfp4-mlx',
|
|
104
|
+
sizeGb: 23,
|
|
105
|
+
description: 'Hybrid Mamba-2 + MoE, native MTP, 1M context',
|
|
106
|
+
hidden: true,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/** Visible target repos plus their optional companion downloads. */
|
|
111
|
+
export function catalogDownloadRepos(): string[] {
|
|
112
|
+
return [
|
|
113
|
+
...new Set(
|
|
114
|
+
MODEL_CATALOG.filter((entry) => !entry.hidden).flatMap((entry) =>
|
|
115
|
+
entry.draft === undefined ? [catalogRepo(entry)] : [catalogRepo(entry), entry.draft.hfRepo],
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The repo THIS platform installs for `entry`. Linux is the CUDA preview
|
|
123
|
+
* target (README "Platform Support"); everything else is Apple Silicon.
|
|
124
|
+
*
|
|
125
|
+
* NOT because Metal cannot run NVFP4 — it can. MLX ships the same 234
|
|
126
|
+
* quantized Metal kernels for `nvfp4` as for `mxfp4`, NAX variants included,
|
|
127
|
+
* and only `fp8_e4m3` reconstructs BF16 at load. The MSL 4.1 hardware
|
|
128
|
+
* block-scale format (`metal_fp8_ue8m0_format`) appears nowhere in MLX's Metal
|
|
129
|
+
* backend, so it constrains neither format here.
|
|
130
|
+
*
|
|
131
|
+
* The split is:
|
|
132
|
+
* - CUDA takes NVFP4 through `CublasQQMM` (`nvfp4` -> `CUDA_R_4F_E2M1`), a
|
|
133
|
+
* native path with no MXFP4 equivalent.
|
|
134
|
+
* - Metal has no such native-path advantage either way, so prefer the format
|
|
135
|
+
* that survives quantization better. NVFP4 stores a block scale as `amax/6`
|
|
136
|
+
* in E4M3, and real FFN blocks land in its subnormal band;
|
|
137
|
+
* `apply_nvfp4_pow2_lift` repairs that for dense SwiGLU FFNs but SKIPS MoE
|
|
138
|
+
* experts by design (`NVFP4_LIFT_MOE_MARKERS`, `crates/mlx-core/src/
|
|
139
|
+
* convert.rs`), because the norm there also drives the router and the
|
|
140
|
+
* shared-expert gate, neither scale-invariant. Two of the three visible
|
|
141
|
+
* entries are MoE. MXFP4's E8M0 block scales have no such failure.
|
|
142
|
+
*
|
|
143
|
+
* Unmeasured: whether nvfp4 or mxfp4 decodes faster on Metal. The choice above
|
|
144
|
+
* is made on quantization quality, not throughput.
|
|
145
|
+
*
|
|
146
|
+
* Every consumer that turns a catalog entry into a download, a slug, or an
|
|
147
|
+
* allowlist check must go through here. Reading `entry.hfRepo` directly
|
|
148
|
+
* installs the macOS build on a CUDA box.
|
|
149
|
+
*/
|
|
150
|
+
export function catalogRepo(entry: CatalogEntry): string {
|
|
151
|
+
return catalogRepoFor(entry, process.platform);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* {@link catalogRepo} with the platform passed in — the pure half.
|
|
156
|
+
*
|
|
157
|
+
* Exists so both branches can be asserted without touching `process.platform`.
|
|
158
|
+
* Mutating that global leaks across test files sharing a worker: it made
|
|
159
|
+
* `catalogRepo` disagree with a sibling suite's module-level constant and fail
|
|
160
|
+
* a download allowlist check that has nothing to do with the catalog.
|
|
161
|
+
*/
|
|
162
|
+
export function catalogRepoFor(entry: CatalogEntry, platform: NodeJS.Platform): string {
|
|
163
|
+
return platform === 'linux' && entry.hfRepoCuda !== undefined ? entry.hfRepoCuda : entry.hfRepo;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Catalog entries the wizard offers (hidden entries filtered out). */
|
|
167
|
+
export function visibleCatalog(): CatalogEntry[] {
|
|
168
|
+
return MODEL_CATALOG.filter((entry) => !entry.hidden);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Cold-tier facts and family registration data, re-exported through this
|
|
173
|
+
* subpath.
|
|
174
|
+
*
|
|
175
|
+
* `@mlx-node/agent/catalog` is a NATIVE-FREE entry point, alongside the
|
|
176
|
+
* `delegate` client and `models` discovery subpaths:
|
|
177
|
+
* the package root re-exports `provider/index.ts`, which value-imports
|
|
178
|
+
* `@mlx-node/core`. The dashboard is a separate viewer process that must never
|
|
179
|
+
* link the addon (docs/dashboard.md: "no Metal init, instant start"), and
|
|
180
|
+
* `mlx agent --help` must print without loading weights — so both reach the
|
|
181
|
+
* cold-tier allowlist, the cache-root canonicalizer, and the family detection
|
|
182
|
+
* data through here.
|
|
183
|
+
*
|
|
184
|
+
* Every module reachable from here must therefore stay free of runtime addon
|
|
185
|
+
* imports. `packages/agent/__test__/catalog-native-free.test.ts` gates that in a
|
|
186
|
+
* real subprocess.
|
|
187
|
+
*/
|
|
188
|
+
export { COLD_TIER_RESTORE_FAMILIES, canonicalCacheRoot, coldTierRestoreFamilyList } from './cold-tier.js';
|
|
189
|
+
export {
|
|
190
|
+
CHAT_FAMILY_IDS,
|
|
191
|
+
matchFamily,
|
|
192
|
+
NON_GENERATIVE_FAMILY_IDS,
|
|
193
|
+
rawModelTypeToCanonical,
|
|
194
|
+
} from '@mlx-node/lm/family-data';
|
package/src/cold-tier.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold-tier facts shared by the agent (writer), the CLI (help text) and the
|
|
3
|
+
* dashboard (reader), kept in a NATIVE-FREE leaf module.
|
|
4
|
+
*
|
|
5
|
+
* `provider/model-host.ts` value-imports `@mlx-node/lm`, which loads the native
|
|
6
|
+
* addon; the dashboard is a separate viewer process that must never link it
|
|
7
|
+
* ("no Metal init, instant start" — docs/dashboard.md), and `mlx agent --help`
|
|
8
|
+
* must print without loading weights. This module therefore has NO runtime
|
|
9
|
+
* imports beyond `node:` builtins, and reaches those consumers through the
|
|
10
|
+
* existing native-free `@mlx-node/agent/catalog` subpath, which re-exports it.
|
|
11
|
+
* There is deliberately no `./cold-tier` entry in `package.json`: a second
|
|
12
|
+
* published subpath would be a second thing to keep native-free, and `catalog`
|
|
13
|
+
* already carries that guarantee.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is a single source of truth. Do not copy the family list or
|
|
16
|
+
* re-implement the root canonicalization at a call site; import them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { realpathSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import type { ModelType } from '@mlx-node/lm';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Model families whose paged prefix blocks can be restored from the SSD cold
|
|
27
|
+
* tier soundly. The single source of truth on the TypeScript side, mirroring
|
|
28
|
+
* `COLD_RESTORE_FAMILIES` in `crates/mlx-core/src/cold_tier.rs`; the two gate
|
|
29
|
+
* the same decision from opposite ends, and
|
|
30
|
+
* `packages/agent/__test__/cold-tier-families.test.ts` asserts they never
|
|
31
|
+
* drift.
|
|
32
|
+
*
|
|
33
|
+
* A family belongs here only when EVERY piece of per-token state its forward
|
|
34
|
+
* pass carries between turns is reconstructible from the tier — either because
|
|
35
|
+
* it all lives inside the paged pool, or because the part that does not is
|
|
36
|
+
* persisted alongside as a cold-tier sidecar and the restore reconciles down to
|
|
37
|
+
* a boundary that sidecar actually backs:
|
|
38
|
+
*
|
|
39
|
+
* - `qwen3` (dense) sizes its pool over all layers, so the pool holds the
|
|
40
|
+
* complete KV for the prefix and needs no sidecar.
|
|
41
|
+
* - `gemma4` owns full and sliding attention in distinct paged groups. The
|
|
42
|
+
* full-attention chain is authoritative and one grouped sliding sidecar
|
|
43
|
+
* restores every sliding group at the same exact boundary, atomically.
|
|
44
|
+
* - `muse_glimmer` uses the same hybrid contract for its 13 full-attention
|
|
45
|
+
* and 39 sliding-attention layers.
|
|
46
|
+
* - `qwen3_5` (dense) sizes its pool over the full-attention layers only, but
|
|
47
|
+
* persists its out-of-pool GDN recurrent state (conv + recurrent) as a
|
|
48
|
+
* `ColdGroup::GdnState` sidecar, and its `ColdSidecarPolicy` reconciles the
|
|
49
|
+
* native restore down to the deepest block-aligned boundary a validated
|
|
50
|
+
* sidecar backs (a recurrent state is valid only at its exact prefix length).
|
|
51
|
+
* - `qwen3_5_moe` keeps the SAME GDN recurrent state outside the pool — same
|
|
52
|
+
* shapes, same dtype, same layer mapping — so it shares the dense family's
|
|
53
|
+
* sidecar codec and `ColdSidecarPolicy` verbatim. Its restart-parity gate has
|
|
54
|
+
* since been run on `Qwen3.6-35B-A3B-mxfp4-mlx`: the restore reconciled
|
|
55
|
+
* onto ladder rung 304 of `[16, 64, 304, 1248]` with `hits=42` and
|
|
56
|
+
* `corruptions=0`, and the text matched a no-persist baseline.
|
|
57
|
+
* - `lfm2` / `lfm2_moe` keep ShortConv state outside the full-attention pool.
|
|
58
|
+
* A `ColdGroup::ConvState` sidecar persists that state at the same exact
|
|
59
|
+
* boundary; missing or malformed state reconciles down or restarts at zero.
|
|
60
|
+
*
|
|
61
|
+
* Widening this set is a correctness decision, never a perf one, and it is
|
|
62
|
+
* authorized by exactly one thing: the family's native restart-parity gate
|
|
63
|
+
* passing on real weights with `hits > 0` and `corruptions == 0`.
|
|
64
|
+
*
|
|
65
|
+
* `mlx agent --no-persist-cache` applies to EVERY family in this set, not to
|
|
66
|
+
* one of them: it is a SINGLE process-wide boolean handed to every load whose
|
|
67
|
+
* family is on the allowlist (`MlxModelHost.runWithResident` →
|
|
68
|
+
* `resolveModelPathFn(entry, { persistPagedCache })`). Families off the
|
|
69
|
+
* allowlist are handed no policy at all — not because the flag spares them, but
|
|
70
|
+
* because `cold_tier::resolve_persist_cold` refuses to persist them under ANY
|
|
71
|
+
* setting. The help text says so in exactly one place, built from
|
|
72
|
+
* {@link coldTierRestoreFamilyList} in `packages/cli/src/commands/agent/index.ts`
|
|
73
|
+
* and asserted by `packages/cli/__test__/agent-cmd.test.ts`.
|
|
74
|
+
*/
|
|
75
|
+
export const COLD_TIER_RESTORE_FAMILIES: ReadonlySet<string> = new Set<ModelType>([
|
|
76
|
+
'qwen3',
|
|
77
|
+
'qwen3_5',
|
|
78
|
+
'qwen3_5_moe',
|
|
79
|
+
'gemma4',
|
|
80
|
+
'muse_glimmer',
|
|
81
|
+
'lfm2',
|
|
82
|
+
'lfm2_moe',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/** Allowlisted families in a stable, human-facing order (help text, API payload). */
|
|
86
|
+
export function coldTierRestoreFamilyList(): string[] {
|
|
87
|
+
return [...COLD_TIER_RESTORE_FAMILIES].sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Expand a leading `~` / `~/…` against the current home directory. A bare `~`
|
|
92
|
+
* prefix that is part of a longer name (`~cache`) is left alone — only the
|
|
93
|
+
* shell's own two spellings are expanded.
|
|
94
|
+
*/
|
|
95
|
+
function expandTilde(path: string): string {
|
|
96
|
+
if (path === '~') return homedir();
|
|
97
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2));
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Canonical identity for a cold-cache root, used as the JOIN KEY between the
|
|
103
|
+
* agent that wrote a trace and the dashboard that reads it back.
|
|
104
|
+
*
|
|
105
|
+
* The two live in DIFFERENT PROCESSES resolving the root from their own
|
|
106
|
+
* environments: the native tier reports `manager.root().display()` (the path as
|
|
107
|
+
* constructed, never realpath'd) while the dashboard builds its own from
|
|
108
|
+
* `MLX_COLD_CACHE_DIR` or the `~/.mlx-node` default. On macOS `/tmp` is a
|
|
109
|
+
* symlink to `/private/tmp`, so two identical-looking spellings canonicalize
|
|
110
|
+
* differently — a raw string compare is a silent-zero trap, not a loud failure.
|
|
111
|
+
* Both sides MUST route through this one function.
|
|
112
|
+
*
|
|
113
|
+
* Resolution order: expand `~`, make absolute, then `realpath` the DEEPEST
|
|
114
|
+
* EXISTING ANCESTOR and re-attach every missing segment lexically. A tier that
|
|
115
|
+
* has never been opened has no root directory — that is the normal state, not
|
|
116
|
+
* an edge case — and when `MLX_COLD_CACHE_DIR` itself has never been created
|
|
117
|
+
* the parent is missing too. Stopping after one level returned a wholly
|
|
118
|
+
* UNcanonicalized path, so under a symlinked ancestor (`/tmp` → `/private/tmp`
|
|
119
|
+
* on macOS) the reader's key diverged from the writer's and rows from the SAME
|
|
120
|
+
* cache were reported as "a different cache directory". Only a path with no
|
|
121
|
+
* resolvable ancestor at all falls back to the lexical absolute form; this is
|
|
122
|
+
* an identity key, not a security boundary, so it fails OPEN to a stable string
|
|
123
|
+
* rather than to `undefined`.
|
|
124
|
+
*
|
|
125
|
+
* An empty input stays empty: the native struct reports `root: ''` while the
|
|
126
|
+
* tier is disabled, and that must never be recorded as a real cache identity.
|
|
127
|
+
*/
|
|
128
|
+
export function canonicalCacheRoot(root: string): string {
|
|
129
|
+
const expanded = expandTilde(root.trim());
|
|
130
|
+
if (expanded.length === 0) return '';
|
|
131
|
+
const absolute = resolve(expanded);
|
|
132
|
+
const missing: string[] = [];
|
|
133
|
+
let cursor = absolute;
|
|
134
|
+
for (;;) {
|
|
135
|
+
try {
|
|
136
|
+
// `realpathSync.native` (realpath(3)), not Node's JS walk: on a
|
|
137
|
+
// case-insensitive volume — APFS's default — `~/CacheDir` and `~/cachedir`
|
|
138
|
+
// name the SAME directory, but `fs.realpathSync` returns whichever spelling
|
|
139
|
+
// the caller typed. `.native` folds each existing component to its on-disk
|
|
140
|
+
// spelling, so both env spellings produce one join key.
|
|
141
|
+
const resolved = realpathSync.native(cursor);
|
|
142
|
+
return missing.length === 0 ? resolved : join(resolved, ...missing);
|
|
143
|
+
} catch {
|
|
144
|
+
const parent = dirname(cursor);
|
|
145
|
+
// `dirname` is a fixpoint at the filesystem root, which is the loop's
|
|
146
|
+
// termination condition: nothing above it is left to canonicalize.
|
|
147
|
+
if (parent === cursor) return absolute;
|
|
148
|
+
missing.unshift(basename(cursor));
|
|
149
|
+
cursor = parent;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/delegate.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Addon-free client for the desktop's global instruction installation checks. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { expandPiAgentDir } from './paths.js';
|
|
7
|
+
|
|
8
|
+
export const DELEGATION_PROMPT =
|
|
9
|
+
'Delegate GitHub investigation to `mlx delegate github --caller-approved --repo OWNER/REPO "TASK"`. Approve the bounded task and its tool execution before invoking. Include the PR, issue, or run number. Wait for the final handoff and read it once; do not poll by dumping worker transcripts. Use its findings and evidence for implementation; address specific gaps with a focused follow-up or relevant evidence excerpt. Add `--allow-write` only for GitHub changes already authorized by the user. If delegation fails or reports incomplete work, continue from its handoff.';
|
|
10
|
+
|
|
11
|
+
/** Absolute paths avoid dependence on each coding agent's shell startup/PATH. */
|
|
12
|
+
export function delegationCommand(path: string): string {
|
|
13
|
+
if (/[\0\r\n`]/.test(path)) throw new Error('The delegation command path cannot be represented in instructions.');
|
|
14
|
+
return `'${path.replaceAll("'", "'\\''")}'`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function delegationPrompt(path: string): string {
|
|
18
|
+
return DELEGATION_PROMPT.replace('`mlx delegate', `\`${delegationCommand(path)} delegate`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LocalInferenceConnection {
|
|
22
|
+
url: string;
|
|
23
|
+
token?: string;
|
|
24
|
+
model: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LocalMessage {
|
|
28
|
+
role: 'user' | 'assistant';
|
|
29
|
+
content: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Shared by setup requests and their verdict cache key. Includes thinking and final output. */
|
|
33
|
+
export const INSTALL_CHECK_GENERATION = Object.freeze({
|
|
34
|
+
reasoning: Object.freeze({ effort: 'medium' as const }),
|
|
35
|
+
max_output_tokens: 16384,
|
|
36
|
+
temperature: 0,
|
|
37
|
+
});
|
|
38
|
+
export const INSTALL_CHECK_TIMEOUT_MS = 600_000;
|
|
39
|
+
|
|
40
|
+
export function expandHome(path: string, home = homedir()): string {
|
|
41
|
+
return path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Match the persisted default used by `mlx agent`; never select a cloud provider. */
|
|
45
|
+
export async function preferredLocalModel(home = homedir(), env = process.env): Promise<string | undefined> {
|
|
46
|
+
try {
|
|
47
|
+
const dir = env.PI_CODING_AGENT_DIR
|
|
48
|
+
? expandPiAgentDir(env.PI_CODING_AGENT_DIR, home)
|
|
49
|
+
: join(home, '.mlx-node', 'agent');
|
|
50
|
+
const value = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
|
|
51
|
+
if (value.defaultProvider === 'mlx' && typeof value.defaultModel === 'string') {
|
|
52
|
+
return value.defaultModel.replace(/^mlx\//, '');
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
56
|
+
throw new Error('Could not read the default local model. Open mlx agent and select a model again.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Refuse remote endpoints even if a caller accidentally supplies a cloud API URL. */
|
|
63
|
+
export async function localCompletion(
|
|
64
|
+
connection: LocalInferenceConnection,
|
|
65
|
+
system: string,
|
|
66
|
+
messages: LocalMessage[],
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
maxTokens: number = INSTALL_CHECK_GENERATION.max_output_tokens,
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const url = new URL(connection.url);
|
|
71
|
+
if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) {
|
|
72
|
+
throw new Error('Delegation requires a local inference server.');
|
|
73
|
+
}
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const cancel = (): void => controller.abort(signal?.reason);
|
|
76
|
+
if (signal?.aborted) cancel();
|
|
77
|
+
else signal?.addEventListener('abort', cancel, { once: true });
|
|
78
|
+
const timer = setTimeout(
|
|
79
|
+
() => controller.abort(new Error('The local model request timed out.')),
|
|
80
|
+
INSTALL_CHECK_TIMEOUT_MS,
|
|
81
|
+
);
|
|
82
|
+
try {
|
|
83
|
+
const headers = {
|
|
84
|
+
'content-type': 'application/json',
|
|
85
|
+
...(connection.token ? { 'x-api-key': connection.token } : {}),
|
|
86
|
+
};
|
|
87
|
+
const catalog = await fetch(new URL('/v1/models', url), { headers, signal: controller.signal, redirect: 'error' });
|
|
88
|
+
if (!catalog.ok) throw new Error('The local model service is unavailable. Restart it and try again.');
|
|
89
|
+
const models = (await catalog.json()) as { data?: { id: string }[] };
|
|
90
|
+
if (!models.data?.some((model) => model.id === connection.model)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'The default model is not available in the running service. Restart the local model service and try again.',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const response = await fetch(new URL('/v1/responses', url), {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
model: connection.model,
|
|
100
|
+
instructions: system,
|
|
101
|
+
input: messages,
|
|
102
|
+
...INSTALL_CHECK_GENERATION,
|
|
103
|
+
max_output_tokens: maxTokens,
|
|
104
|
+
store: false,
|
|
105
|
+
stream: false,
|
|
106
|
+
}),
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
redirect: 'error',
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok) throw new Error(`The local model could not complete the request (HTTP ${response.status}).`);
|
|
111
|
+
const body = (await response.json()) as {
|
|
112
|
+
status?: string;
|
|
113
|
+
output_text?: string;
|
|
114
|
+
incomplete_details?: { reason?: string } | null;
|
|
115
|
+
};
|
|
116
|
+
if (body.incomplete_details?.reason === 'max_output_tokens')
|
|
117
|
+
throw new Error('The local model ran out of output space. Try checking again.');
|
|
118
|
+
if (body.status !== 'completed')
|
|
119
|
+
throw new Error('The local model did not complete its answer. Try checking again.');
|
|
120
|
+
const text = body.output_text?.trim();
|
|
121
|
+
if (!text) throw new Error('The local model returned no answer. Try checking again.');
|
|
122
|
+
return text;
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
signal?.removeEventListener('abort', cancel);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseLocalJson(text: string): unknown {
|
|
130
|
+
return JSON.parse(
|
|
131
|
+
text
|
|
132
|
+
.trim()
|
|
133
|
+
.replace(/^```(?:json)?\s*\n?/, '')
|
|
134
|
+
.replace(/\n?```$/, ''),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Longest detail line (in chars) shown in an approval prompt. */
|
|
2
|
+
const DETAIL_MAX_CHARS = 500;
|
|
3
|
+
/** Most detail lines shown before truncation kicks in. */
|
|
4
|
+
const DETAIL_MAX_LINES = 6;
|
|
5
|
+
const TRUNCATION_MARKER = '… [truncated]';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every character that must be rendered visibly instead of reaching the
|
|
9
|
+
* terminal: C0 controls except `\n` and `\t`, DEL, and the C1 range
|
|
10
|
+
* U+0080–U+009F (which contains the raw CSI/OSC/ST bytes U+009B, U+009D
|
|
11
|
+
* and U+009C). Matched one character at a time — deliberately NOT as
|
|
12
|
+
* multi-character escape "sequences": the printable bytes inside those
|
|
13
|
+
* sequences still belong to the approval detail and must remain visible.
|
|
14
|
+
*/
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
const CONTROL_CHAR_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
17
|
+
|
|
18
|
+
/** Render one control character as visible `\xNN` text (e.g. ESC → `\x1b`). */
|
|
19
|
+
function encodeControlChar(ch: string): string {
|
|
20
|
+
return `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitize untrusted text before embedding it in an approval prompt.
|
|
25
|
+
*
|
|
26
|
+
* Pi's TUI preserves ANSI, so every terminal control byte is encoded rather
|
|
27
|
+
* than deleted. Printable text is retained verbatim, and the rendered detail
|
|
28
|
+
* is capped by line and character count with a visible truncation marker.
|
|
29
|
+
*/
|
|
30
|
+
export function sanitizeApprovalDetail(text: string): string {
|
|
31
|
+
let out = text.replace(CONTROL_CHAR_RE, encodeControlChar);
|
|
32
|
+
let truncated = false;
|
|
33
|
+
|
|
34
|
+
const lines = out.split('\n');
|
|
35
|
+
if (lines.length > DETAIL_MAX_LINES) {
|
|
36
|
+
out = lines.slice(0, DETAIL_MAX_LINES).join('\n');
|
|
37
|
+
truncated = true;
|
|
38
|
+
}
|
|
39
|
+
if (out.length > DETAIL_MAX_CHARS) {
|
|
40
|
+
out = out.slice(0, DETAIL_MAX_CHARS);
|
|
41
|
+
// Do not leave a lone high surrogate behind after the hard cut.
|
|
42
|
+
const last = out.charCodeAt(out.length - 1);
|
|
43
|
+
if (last >= 0xd800 && last <= 0xdbff) {
|
|
44
|
+
out = out.slice(0, -1);
|
|
45
|
+
}
|
|
46
|
+
truncated = true;
|
|
47
|
+
}
|
|
48
|
+
if (truncated) {
|
|
49
|
+
out += ` ${TRUNCATION_MARKER}`;
|
|
50
|
+
}
|
|
51
|
+
if (out.trim().length === 0 && text.length > 0) {
|
|
52
|
+
// Control characters always encode to visible text, so this only fires
|
|
53
|
+
// for whitespace-only input.
|
|
54
|
+
return '(unprintable content)';
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, InlineExtension } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export interface DelegateCallerPermissions {
|
|
4
|
+
source: 'codex';
|
|
5
|
+
/** Opaque Codex profile/backend when supplied; otherwise just inherited process permissions. */
|
|
6
|
+
profile: string;
|
|
7
|
+
networkDisabled: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Child processes stay inside Codex's actual sandbox; this metadata grants no OS access. */
|
|
11
|
+
export function delegateCallerPermissions(env: NodeJS.ProcessEnv = process.env): DelegateCallerPermissions | undefined {
|
|
12
|
+
if (!env.CODEX_THREAD_ID?.trim()) return undefined;
|
|
13
|
+
const profile = env.CODEX_PERMISSION_PROFILE?.trim();
|
|
14
|
+
// Permission metadata is optional in Codex launches. The thread identifies
|
|
15
|
+
// the caller; subprocesses inherit its real sandbox regardless of these labels.
|
|
16
|
+
const sandbox = env.CODEX_SANDBOX;
|
|
17
|
+
return {
|
|
18
|
+
source: 'codex',
|
|
19
|
+
profile: profile || (sandbox === 'seatbelt' || sandbox === 'landlock' ? `sandbox:${sandbox}` : 'inherited'),
|
|
20
|
+
networkDisabled: env.CODEX_SANDBOX_NETWORK_DISABLED === '1',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const READ_TOOLS = new Set(['read', 'grep', 'find', 'ls']);
|
|
25
|
+
|
|
26
|
+
/** Only failed tool executions qualify; quoted CI logs in a successful result do not. */
|
|
27
|
+
export function delegationBlocker(text: string): boolean {
|
|
28
|
+
return /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b|network access was denied|sandbox.*(?:denied|blocked)|(?:HTTP\s+|status(?: code)?[: ]+)(?:401|403)\b|gh auth login|could not resolve host|could not resolve proxy|failed to connect|error connecting to api\.github\.com/i.test(
|
|
29
|
+
text,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createDelegationExtension(options: { callerApproved?: boolean } = {}): InlineExtension {
|
|
34
|
+
// Capture before any model/tool code runs. Never consult model-written settings.
|
|
35
|
+
const caller = delegateCallerPermissions();
|
|
36
|
+
const explicitlyApproved = options.callerApproved === true || process.env.MLX_AGENT_AUTO_APPROVE === '1';
|
|
37
|
+
return {
|
|
38
|
+
name: 'mlx-delegation',
|
|
39
|
+
factory: (pi: ExtensionAPI) => {
|
|
40
|
+
let handoff: string | undefined;
|
|
41
|
+
|
|
42
|
+
const stop = (reason: string, ctx: ExtensionContext): string => {
|
|
43
|
+
if (!handoff) {
|
|
44
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
45
|
+
handoff = `Delegation incomplete: ${reason}\nContinue this task in the calling agent. Do not retry with changed permissions or another agent.${sessionFile ? `\nSaved evidence and tool results: ${sessionFile}` : ''}`;
|
|
46
|
+
pi.appendEntry('mlx-delegate-handoff', { reason, caller, sessionFile });
|
|
47
|
+
// Print mode otherwise shows only the final assistant message. A deterministic
|
|
48
|
+
// diagnostic and failure status let the caller recover without another inference.
|
|
49
|
+
process.stderr.write(`${handoff}\n`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|
|
52
|
+
ctx.abort();
|
|
53
|
+
return handoff;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
57
|
+
handoff = undefined;
|
|
58
|
+
// A boundary per invocation keeps ordinary history/resumes out of the
|
|
59
|
+
// delegate estimate. Custom entries are persisted, never model context.
|
|
60
|
+
pi.appendEntry('mlx-delegate-session', { version: 1, sessionId: ctx.sessionManager.getSessionId() });
|
|
61
|
+
const permissions = caller
|
|
62
|
+
? `Tool execution inherits the calling Codex process's permissions (${caller.profile}).${caller.networkDisabled ? ' The caller disables network access.' : ''} Additional approval must be handled by the calling agent; this worker cannot request escalation.`
|
|
63
|
+
: explicitlyApproved
|
|
64
|
+
? "The caller explicitly approved tool execution for this bounded task. Child processes inherit its OS sandbox, environment and credentials; this does not copy the calling agent's tool approval rules or grant additional access. Stay within the task authorization."
|
|
65
|
+
: 'No inherited caller permission context is available. Tools requiring approval will stop this task and return a handoff.';
|
|
66
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${permissions}` };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.on('tool_call', (event, ctx) => {
|
|
70
|
+
if (handoff) return { block: true, terminate: true, reason: handoff };
|
|
71
|
+
if (event.toolName === 'subagent') {
|
|
72
|
+
return { block: true, terminate: true, reason: stop('This worker cannot create subagents.', ctx) };
|
|
73
|
+
}
|
|
74
|
+
if (READ_TOOLS.has(event.toolName)) return undefined;
|
|
75
|
+
if (!caller && !explicitlyApproved) {
|
|
76
|
+
return {
|
|
77
|
+
block: true,
|
|
78
|
+
terminate: true,
|
|
79
|
+
reason: stop(
|
|
80
|
+
`No caller permission context is available to authorize ${event.toolName}. The calling agent can use --caller-approved after approving this bounded task.`,
|
|
81
|
+
ctx,
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (event.toolName === 'bash') {
|
|
86
|
+
const input = event.input as { command?: unknown };
|
|
87
|
+
if (typeof input.command === 'string') {
|
|
88
|
+
// Preserve failures through pipelines such as `gh ... | head`, and
|
|
89
|
+
// stop a multi-command script before later output masks a denial.
|
|
90
|
+
// This changes shell options only; cwd, environment and sandbox remain inherited.
|
|
91
|
+
input.command = `set -e -o pipefail\n${input.command}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.on('tool_result', (event, ctx) => {
|
|
98
|
+
if (!event.isError) return undefined;
|
|
99
|
+
const text = event.content
|
|
100
|
+
.filter((part) => part.type === 'text')
|
|
101
|
+
.map((part) => part.text)
|
|
102
|
+
.join('\n');
|
|
103
|
+
if (!delegationBlocker(text)) return undefined;
|
|
104
|
+
const reason = stop(`${event.toolName} was blocked. ${text.slice(0, 2000)}`, ctx);
|
|
105
|
+
return { content: [{ type: 'text', text: reason }], isError: true };
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|