@mnemom/mnemom 0.17.2-next.3 → 0.17.2-next.4
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 +20 -0
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +79 -0
- package/dist/commands/agent-goal.js +276 -0
- package/dist/commands/agent-sessions.d.ts +13 -3
- package/dist/commands/agent-sessions.js +46 -4
- package/dist/commands/agent.d.ts +41 -2
- package/dist/commands/agent.js +190 -8
- package/dist/index.js +67 -0
- package/dist/lib/agent-ledger.d.ts +8 -0
- package/dist/lib/agent.d.ts +107 -4
- package/dist/lib/agent.js +87 -6
- package/dist/lib/goal-api.d.ts +133 -0
- package/dist/lib/goal-api.js +113 -0
- package/dist/lib/launcher-config.d.ts +6 -0
- package/dist/lib/launcher-config.js +15 -0
- package/dist/mcp/handoff-server.mjs +331 -0
- package/dist/rc-proxy.mjs +113 -37
- package/package.json +1 -1
package/dist/lib/agent.js
CHANGED
|
@@ -50,6 +50,18 @@ export function resolveAnthropicDoor(env = process.env) {
|
|
|
50
50
|
export function isProdGateway(env = process.env) {
|
|
51
51
|
return resolveGatewayHost(env) === PROD_GATEWAY_HOST;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The gateway origin the `handoff` MCP tool's own `POST /mnemom/context/handoff` must hit —
|
|
55
|
+
* always the SAME cell the launched session's own `/v1/messages` traffic goes to. Derived from
|
|
56
|
+
* the already-resolved Anthropic `door` (scheme+host only, via `new URL(...).origin`) rather
|
|
57
|
+
* than `resolveGatewayHost` directly, because `MNEMOM_AGENT_ANTHROPIC_GATEWAY` can override the
|
|
58
|
+
* door to a DIFFERENT host than `MNEMOM_AGENT_GATEWAY`/the default (e.g. a test cell) — reading
|
|
59
|
+
* `resolveGatewayHost` here would silently send handoff calls to prod (or vice versa) while the
|
|
60
|
+
* session's model traffic went elsewhere (MNE-7564 review).
|
|
61
|
+
*/
|
|
62
|
+
export function resolveHandoffGatewayHost(door) {
|
|
63
|
+
return new URL(door).origin;
|
|
64
|
+
}
|
|
53
65
|
/**
|
|
54
66
|
* Sanitise a header value into a safe wire token: lowercase, alphanumerics and
|
|
55
67
|
* single hyphens only, no leading/trailing hyphen. Returns "" when nothing
|
|
@@ -195,6 +207,15 @@ export const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
|
|
|
195
207
|
export const SESSION_CONFIG_HEADER = "x-mnemom-session-config";
|
|
196
208
|
/** The gateway env key that gates context folding (allow-listed for per-session overlay). */
|
|
197
209
|
export const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
|
|
210
|
+
/**
|
|
211
|
+
* The gateway env key that gates the whole context-handoff capability (level lines +
|
|
212
|
+
* `/mnemom/context/handoff` route + the digest note section) for a single session
|
|
213
|
+
* (MNE-7561..7565). Off by default on the gateway; turned on ONLY when this launch's
|
|
214
|
+
* `contextHandoff` setting is true (see `resolveContextHandoff`), which also registers the
|
|
215
|
+
* `handoff` MCP tool (MNE-7564) — a session never gets the tool without the header, or the
|
|
216
|
+
* header without the tool.
|
|
217
|
+
*/
|
|
218
|
+
export const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
|
|
198
219
|
const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
|
|
199
220
|
/**
|
|
200
221
|
* `--implicit` and a sealed contract are mutually exclusive: on the gateway a
|
|
@@ -393,16 +414,76 @@ export function resolveContextFold(opts) {
|
|
|
393
414
|
return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
|
|
394
415
|
}
|
|
395
416
|
/**
|
|
396
|
-
* The `x-mnemom-session-config` value
|
|
397
|
-
*
|
|
398
|
-
*
|
|
417
|
+
* The `x-mnemom-session-config` value for this launch, or `undefined` when there is
|
|
418
|
+
* nothing to override (the gateway's own defaults stand — the common case). Two
|
|
419
|
+
* independent overlays, both OFF by default (folding is ON by default so only its OFF
|
|
420
|
+
* state is ever sent; handoff is OFF by default so only its ON state is ever sent):
|
|
421
|
+
* - `fold: false` → `CONTEXT_RUNTIME_ENABLED: "false"` (`--no-context`).
|
|
422
|
+
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
423
|
+
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
424
|
+
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
425
|
+
* per request (see `commands/agent.ts` handoff MCP wiring).
|
|
426
|
+
* base64url so it survives the gateway's inbound strip.
|
|
399
427
|
*/
|
|
400
|
-
export function buildSessionConfigB64(
|
|
401
|
-
|
|
428
|
+
export function buildSessionConfigB64(opts) {
|
|
429
|
+
const config = {};
|
|
430
|
+
if (!opts.fold)
|
|
431
|
+
config[CONTEXT_RUNTIME_KEY] = "false";
|
|
432
|
+
if (opts.contextHandoff)
|
|
433
|
+
config[CONTEXT_HANDOFF_KEY] = "true";
|
|
434
|
+
if (Object.keys(config).length === 0)
|
|
402
435
|
return undefined;
|
|
403
|
-
const payload = JSON.stringify({ v: 1, config
|
|
436
|
+
const payload = JSON.stringify({ v: 1, config });
|
|
404
437
|
return Buffer.from(payload, "utf8").toString("base64url");
|
|
405
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
|
|
441
|
+
* this launch? OFF by default (operator directive, curation 2026-09-25): an absent flag
|
|
442
|
+
* and an absent `context_handoff` setting are both "off", matching `resolveContextFold`'s
|
|
443
|
+
* shape but the opposite default. Precedence: an explicit `--context-handoff`/
|
|
444
|
+
* `--no-context-handoff` flag wins, else `MNEMOM_AGENT_CONTEXT_HANDOFF` (which
|
|
445
|
+
* `applyConfigToEnv` sets from the `context_handoff` config key), else OFF.
|
|
446
|
+
*/
|
|
447
|
+
export function resolveContextHandoff(opts) {
|
|
448
|
+
if (opts.contextHandoff !== undefined)
|
|
449
|
+
return opts.contextHandoff;
|
|
450
|
+
const raw = (opts.env?.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim().toLowerCase();
|
|
451
|
+
if (raw === "")
|
|
452
|
+
return false;
|
|
453
|
+
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
454
|
+
}
|
|
455
|
+
/** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
|
|
456
|
+
export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
|
|
457
|
+
/**
|
|
458
|
+
* The name of the non-`ANTHROPIC_*` env var `spawnRcCli` (commands/agent.ts) adds to Claude
|
|
459
|
+
* Code's Remote Control child env, carrying the real Anthropic key, ONLY when context handoff is
|
|
460
|
+
* on. `spawnRcCli` strips every `ANTHROPIC_*` auth var (including `ANTHROPIC_CUSTOM_HEADERS`) so
|
|
461
|
+
* the RC gate sees a clean first-party-OAuth setup, but that gate only inspects the recognized
|
|
462
|
+
* `ANTHROPIC_*` base-url/key/socket family — a custom-named var rides through ungated, exactly
|
|
463
|
+
* like `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` already do for the proxy. The handoff server inherits
|
|
464
|
+
* it the same way it inherits `ANTHROPIC_CUSTOM_HEADERS` under the terminal shape.
|
|
465
|
+
*/
|
|
466
|
+
export const HANDOFF_RC_KEY_ENV_NAME = "MNEMOM_HANDOFF_KEY";
|
|
467
|
+
/** The `--mcp-config` document registering exactly the `handoff` tool. Pure — no I/O. */
|
|
468
|
+
export function buildHandoffMcpConfig(p) {
|
|
469
|
+
const env = {
|
|
470
|
+
MNEMOM_HANDOFF_GATEWAY_HOST: p.gatewayHost,
|
|
471
|
+
MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME: p.anthropicKeySource.envName,
|
|
472
|
+
MNEMOM_HANDOFF_AGENT: p.agent,
|
|
473
|
+
MNEMOM_HANDOFF_CONVERSATION_ID: p.conversationId,
|
|
474
|
+
};
|
|
475
|
+
if (p.sessionConfigB64)
|
|
476
|
+
env.MNEMOM_HANDOFF_SESSION_CONFIG = p.sessionConfigB64;
|
|
477
|
+
return {
|
|
478
|
+
mcpServers: {
|
|
479
|
+
[HANDOFF_MCP_SERVER_NAME]: { command: p.nodeBin, args: [p.serverScript], env },
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/** `JSON.stringify(buildHandoffMcpConfig(p))` — the exact bytes written to the config file. */
|
|
484
|
+
export function buildHandoffMcpConfigJson(p) {
|
|
485
|
+
return JSON.stringify(buildHandoffMcpConfig(p));
|
|
486
|
+
}
|
|
406
487
|
/**
|
|
407
488
|
* Is this launch implicit-mode? Precedence, highest wins:
|
|
408
489
|
* 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client for the gateway's implicit-goal control route (implicit-goal CLI, cli-telemetry
|
|
3
|
+
* §1.2 / §1.4; the route is gateway/src/goal-intent-control.ts):
|
|
4
|
+
*
|
|
5
|
+
* GET https://<gateway_host>/mnemom/goal/intent?conversation_id=…&thread=…
|
|
6
|
+
* POST https://<gateway_host>/mnemom/goal/intent/pin {conversation_id, thread, pinned}
|
|
7
|
+
* POST https://<gateway_host>/mnemom/goal/intent/reset {conversation_id, thread, statement?, requirements?}
|
|
8
|
+
*
|
|
9
|
+
* Auth is the SAME credential the launch used: `x-api-key` (the Anthropic key from
|
|
10
|
+
* `resolveAnthropicKey`), `x-mnemom-agent: <record.agent_slug>`, and `x-mnemom-api-key` when the
|
|
11
|
+
* launch had a Mnemom key. The gateway hashes them exactly as a proxied request and only LOOKS
|
|
12
|
+
* UP the agent. Key material is never printed: errors carry the status, the gateway's error code
|
|
13
|
+
* and its message, nothing else.
|
|
14
|
+
*
|
|
15
|
+
* Pure apart from the injected `fetch`, so every arm is unit-tested.
|
|
16
|
+
*/
|
|
17
|
+
import type { SessionRecord } from "./agent-ledger.js";
|
|
18
|
+
export type GoalOp = "show" | "pin" | "reset";
|
|
19
|
+
/**
|
|
20
|
+
* A thread id the gateway accepts (PLAN K3 plus `all` for reads, plus the `m.<sha16>` per-process
|
|
21
|
+
* main ids the gateway's goal-thread.ts THREAD_ID_RE also accepts). Validated locally so a typo
|
|
22
|
+
* never costs a round trip.
|
|
23
|
+
*/
|
|
24
|
+
export declare const GOAL_THREAD_RE: RegExp;
|
|
25
|
+
export declare function isValidGoalThread(thread: string): boolean;
|
|
26
|
+
/** The ledger fields the control call needs. */
|
|
27
|
+
export type GoalControlRecord = Pick<SessionRecord, "gateway_host" | "agent_slug" | "conversation_id">;
|
|
28
|
+
export interface GoalControlCreds {
|
|
29
|
+
/** The Anthropic key the launch sent as `x-api-key`. */
|
|
30
|
+
anthropicKey: string;
|
|
31
|
+
/** The Mnemom key, only when the launch sent `x-mnemom-api-key`. */
|
|
32
|
+
mnemomKey?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
35
|
+
export interface GoalControlDeps {
|
|
36
|
+
fetch: FetchLike;
|
|
37
|
+
creds: GoalControlCreds;
|
|
38
|
+
}
|
|
39
|
+
export interface GoalShowBodyInput {
|
|
40
|
+
thread?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface GoalPinBodyInput {
|
|
43
|
+
thread?: string;
|
|
44
|
+
pinned: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface GoalResetBodyInput {
|
|
47
|
+
thread?: string;
|
|
48
|
+
statement?: string;
|
|
49
|
+
requirements?: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface GoalPinned {
|
|
52
|
+
at: string;
|
|
53
|
+
revision: number;
|
|
54
|
+
by: "cli";
|
|
55
|
+
}
|
|
56
|
+
export interface GoalHistoryEntry {
|
|
57
|
+
revision: number;
|
|
58
|
+
hash: string;
|
|
59
|
+
class: string;
|
|
60
|
+
ops: number;
|
|
61
|
+
at: string;
|
|
62
|
+
source: string | null;
|
|
63
|
+
}
|
|
64
|
+
export interface GoalIntentView {
|
|
65
|
+
intent_id: string;
|
|
66
|
+
revision: number;
|
|
67
|
+
contract_hash: string;
|
|
68
|
+
statement: string;
|
|
69
|
+
requirements: string[];
|
|
70
|
+
allowed_paths: string[];
|
|
71
|
+
forbidden_paths: string[];
|
|
72
|
+
pinned: GoalPinned | null;
|
|
73
|
+
pending_count: number;
|
|
74
|
+
history: GoalHistoryEntry[];
|
|
75
|
+
revisions_10m: number;
|
|
76
|
+
gates_10m: number;
|
|
77
|
+
updated_at: string;
|
|
78
|
+
}
|
|
79
|
+
export interface GoalVerdictView {
|
|
80
|
+
verdict: string;
|
|
81
|
+
turn: number;
|
|
82
|
+
at: string;
|
|
83
|
+
superseded: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface GoalThreadRow {
|
|
86
|
+
thread: string;
|
|
87
|
+
role: string;
|
|
88
|
+
revision: number;
|
|
89
|
+
statement: string | null;
|
|
90
|
+
pinned: boolean;
|
|
91
|
+
}
|
|
92
|
+
/** `GET /mnemom/goal/intent` (cli-telemetry §1.2 plus the `thread` echo and `last_revision`). */
|
|
93
|
+
export interface GoalShowResponse {
|
|
94
|
+
conversation_id: string;
|
|
95
|
+
thread: string;
|
|
96
|
+
mode: "implicit" | "sealed" | "none";
|
|
97
|
+
sealed_hash?: string;
|
|
98
|
+
intent: GoalIntentView | null;
|
|
99
|
+
verdict: GoalVerdictView | null;
|
|
100
|
+
last_revision: number;
|
|
101
|
+
threads?: GoalThreadRow[];
|
|
102
|
+
}
|
|
103
|
+
export interface GoalPinResponse {
|
|
104
|
+
conversation_id: string;
|
|
105
|
+
thread: string;
|
|
106
|
+
pinned: GoalPinned | null;
|
|
107
|
+
revision: number;
|
|
108
|
+
}
|
|
109
|
+
export interface GoalResetResponse {
|
|
110
|
+
conversation_id: string;
|
|
111
|
+
thread: string;
|
|
112
|
+
reset: true;
|
|
113
|
+
revision: number;
|
|
114
|
+
prior_revision: number;
|
|
115
|
+
intent: GoalIntentView | null;
|
|
116
|
+
verdict_superseded: boolean;
|
|
117
|
+
}
|
|
118
|
+
/** A non-2xx answer (status > 0) or a transport failure (status 0). Never carries key material. */
|
|
119
|
+
export declare class GoalControlError extends Error {
|
|
120
|
+
readonly status: number;
|
|
121
|
+
readonly code: string | null;
|
|
122
|
+
constructor(status: number, code: string | null, message: string);
|
|
123
|
+
}
|
|
124
|
+
/** `https://<host>` for a bare host; a host that already names a scheme is kept (trailing / dropped). */
|
|
125
|
+
export declare function gatewayOrigin(host: string): string;
|
|
126
|
+
export declare const GOAL_CONTROL_PATH = "/mnemom/goal/intent";
|
|
127
|
+
/** The route URL for one op (the show query carries the conversation id and thread). */
|
|
128
|
+
export declare function goalControlUrl(record: GoalControlRecord, op: GoalOp, thread?: string): string;
|
|
129
|
+
/** The launch's identity headers (never logged). */
|
|
130
|
+
export declare function goalControlHeaders(record: GoalControlRecord, creds: GoalControlCreds): Record<string, string>;
|
|
131
|
+
export declare function goalControlFetch(record: GoalControlRecord, op: "show", body: GoalShowBodyInput, deps: GoalControlDeps): Promise<GoalShowResponse>;
|
|
132
|
+
export declare function goalControlFetch(record: GoalControlRecord, op: "pin", body: GoalPinBodyInput, deps: GoalControlDeps): Promise<GoalPinResponse>;
|
|
133
|
+
export declare function goalControlFetch(record: GoalControlRecord, op: "reset", body: GoalResetBodyInput, deps: GoalControlDeps): Promise<GoalResetResponse>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client for the gateway's implicit-goal control route (implicit-goal CLI, cli-telemetry
|
|
3
|
+
* §1.2 / §1.4; the route is gateway/src/goal-intent-control.ts):
|
|
4
|
+
*
|
|
5
|
+
* GET https://<gateway_host>/mnemom/goal/intent?conversation_id=…&thread=…
|
|
6
|
+
* POST https://<gateway_host>/mnemom/goal/intent/pin {conversation_id, thread, pinned}
|
|
7
|
+
* POST https://<gateway_host>/mnemom/goal/intent/reset {conversation_id, thread, statement?, requirements?}
|
|
8
|
+
*
|
|
9
|
+
* Auth is the SAME credential the launch used: `x-api-key` (the Anthropic key from
|
|
10
|
+
* `resolveAnthropicKey`), `x-mnemom-agent: <record.agent_slug>`, and `x-mnemom-api-key` when the
|
|
11
|
+
* launch had a Mnemom key. The gateway hashes them exactly as a proxied request and only LOOKS
|
|
12
|
+
* UP the agent. Key material is never printed: errors carry the status, the gateway's error code
|
|
13
|
+
* and its message, nothing else.
|
|
14
|
+
*
|
|
15
|
+
* Pure apart from the injected `fetch`, so every arm is unit-tested.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A thread id the gateway accepts (PLAN K3 plus `all` for reads, plus the `m.<sha16>` per-process
|
|
19
|
+
* main ids the gateway's goal-thread.ts THREAD_ID_RE also accepts). Validated locally so a typo
|
|
20
|
+
* never costs a round trip.
|
|
21
|
+
*/
|
|
22
|
+
export const GOAL_THREAD_RE = /^(?:main|all|(?:a|t|af|q|m)\.[A-Za-z0-9._:-]{1,80})$/;
|
|
23
|
+
export function isValidGoalThread(thread) {
|
|
24
|
+
return GOAL_THREAD_RE.test(thread);
|
|
25
|
+
}
|
|
26
|
+
/** A non-2xx answer (status > 0) or a transport failure (status 0). Never carries key material. */
|
|
27
|
+
export class GoalControlError extends Error {
|
|
28
|
+
status;
|
|
29
|
+
code;
|
|
30
|
+
constructor(status, code, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "GoalControlError";
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `https://<host>` for a bare host; a host that already names a scheme is kept (trailing / dropped). */
|
|
38
|
+
export function gatewayOrigin(host) {
|
|
39
|
+
const h = host.trim().replace(/\/+$/, "");
|
|
40
|
+
return /^https?:\/\//i.test(h) ? h : `https://${h}`;
|
|
41
|
+
}
|
|
42
|
+
export const GOAL_CONTROL_PATH = "/mnemom/goal/intent";
|
|
43
|
+
/** The route URL for one op (the show query carries the conversation id and thread). */
|
|
44
|
+
export function goalControlUrl(record, op, thread = "main") {
|
|
45
|
+
const base = `${gatewayOrigin(record.gateway_host)}${GOAL_CONTROL_PATH}`;
|
|
46
|
+
if (op === "show") {
|
|
47
|
+
const q = new URLSearchParams({ conversation_id: record.conversation_id, thread });
|
|
48
|
+
return `${base}?${q.toString()}`;
|
|
49
|
+
}
|
|
50
|
+
return `${base}/${op}`;
|
|
51
|
+
}
|
|
52
|
+
/** The launch's identity headers (never logged). */
|
|
53
|
+
export function goalControlHeaders(record, creds) {
|
|
54
|
+
const headers = {
|
|
55
|
+
"x-api-key": creds.anthropicKey,
|
|
56
|
+
"x-mnemom-agent": record.agent_slug,
|
|
57
|
+
accept: "application/json",
|
|
58
|
+
};
|
|
59
|
+
if (creds.mnemomKey)
|
|
60
|
+
headers["x-mnemom-api-key"] = creds.mnemomKey;
|
|
61
|
+
return headers;
|
|
62
|
+
}
|
|
63
|
+
/** `{error:{code,message}}` (the gateway's envelope) → its parts; anything else → nulls. */
|
|
64
|
+
function errorParts(body) {
|
|
65
|
+
if (!body || typeof body !== "object")
|
|
66
|
+
return { code: null, message: null };
|
|
67
|
+
const e = body.error;
|
|
68
|
+
if (!e || typeof e !== "object")
|
|
69
|
+
return { code: null, message: null };
|
|
70
|
+
const { code, message } = e;
|
|
71
|
+
return {
|
|
72
|
+
code: typeof code === "string" ? code : null,
|
|
73
|
+
message: typeof message === "string" ? message : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Call the control route. Resolves with the parsed 2xx body; rejects with a GoalControlError
|
|
78
|
+
* (status 0 for a transport failure or a 2xx that is not JSON).
|
|
79
|
+
*/
|
|
80
|
+
export async function goalControlFetch(record, op, body, deps) {
|
|
81
|
+
const thread = body.thread ?? "main";
|
|
82
|
+
const url = goalControlUrl(record, op, thread);
|
|
83
|
+
const headers = goalControlHeaders(record, deps.creds);
|
|
84
|
+
const init = { method: op === "show" ? "GET" : "POST", headers };
|
|
85
|
+
if (op !== "show") {
|
|
86
|
+
headers["content-type"] = "application/json";
|
|
87
|
+
init.body = JSON.stringify({ conversation_id: record.conversation_id, ...body, thread });
|
|
88
|
+
}
|
|
89
|
+
let res;
|
|
90
|
+
try {
|
|
91
|
+
res = await deps.fetch(url, init);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
95
|
+
throw new GoalControlError(0, null, `could not reach ${gatewayOrigin(record.gateway_host)}: ${why}`);
|
|
96
|
+
}
|
|
97
|
+
const text = await res.text().catch(() => "");
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = text ? JSON.parse(text) : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
parsed = null;
|
|
104
|
+
}
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const { code, message } = errorParts(parsed);
|
|
107
|
+
throw new GoalControlError(res.status, code, message ?? `HTTP ${res.status}`);
|
|
108
|
+
}
|
|
109
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
110
|
+
throw new GoalControlError(0, null, `unexpected non-JSON response (HTTP ${res.status})`);
|
|
111
|
+
}
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
@@ -33,6 +33,12 @@ export interface LauncherConfig {
|
|
|
33
33
|
goalMode?: GoalModeDefault;
|
|
34
34
|
/** Context folding default (on when unset; `false` disables it for every launch). */
|
|
35
35
|
context?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Context-handoff default (EXPERIMENTAL, off when unset): `true` registers the `handoff`
|
|
38
|
+
* MCP tool and sends `CONTEXT_HANDOFF_ENABLED: "true"` in the per-session config header for
|
|
39
|
+
* every launch. Off means byte-identical to a launch with no MNE-7564 change at all.
|
|
40
|
+
*/
|
|
41
|
+
contextHandoff?: boolean;
|
|
36
42
|
guardrails?: {
|
|
37
43
|
maxTurns?: number;
|
|
38
44
|
budgetUsd?: number;
|
|
@@ -145,6 +145,14 @@ export const CONFIG_KEYS = [
|
|
|
145
145
|
c.context = coerceBool("context", v);
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
|
+
{
|
|
149
|
+
key: "context_handoff",
|
|
150
|
+
describe: "EXPERIMENTAL: register the `handoff` MCP tool + tell the gateway CONTEXT_HANDOFF_ENABLED=true (off by default)",
|
|
151
|
+
get: (c) => c.contextHandoff,
|
|
152
|
+
set: (c, v) => {
|
|
153
|
+
c.contextHandoff = coerceBool("context_handoff", v);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
148
156
|
{
|
|
149
157
|
key: "guardrails.max_turns",
|
|
150
158
|
describe: "Contract guardrail: turn ceiling (positive integer)",
|
|
@@ -213,6 +221,8 @@ export function parseLauncherConfig(text) {
|
|
|
213
221
|
cfg.goalMode = coerceGoalMode(str(raw.goal_mode) ?? "");
|
|
214
222
|
if (raw.context !== undefined)
|
|
215
223
|
cfg.context = coerceBool("context", str(raw.context) ?? "");
|
|
224
|
+
if (raw.context_handoff !== undefined)
|
|
225
|
+
cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
|
|
216
226
|
const g = raw.guardrails;
|
|
217
227
|
if (g !== undefined) {
|
|
218
228
|
if (typeof g !== "object" || g === null || Array.isArray(g)) {
|
|
@@ -273,6 +283,8 @@ export function serializeLauncherConfig(cfg) {
|
|
|
273
283
|
doc.goal_mode = cfg.goalMode;
|
|
274
284
|
if (cfg.context !== undefined)
|
|
275
285
|
doc.context = cfg.context;
|
|
286
|
+
if (cfg.contextHandoff !== undefined)
|
|
287
|
+
doc.context_handoff = cfg.contextHandoff;
|
|
276
288
|
const g = cfg.guardrails;
|
|
277
289
|
if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
|
|
278
290
|
const gr = {};
|
|
@@ -322,4 +334,7 @@ export function applyConfigToEnv(cfg, env = process.env) {
|
|
|
322
334
|
if (cfg.context !== undefined && !(env.MNEMOM_AGENT_CONTEXT ?? "").trim()) {
|
|
323
335
|
env.MNEMOM_AGENT_CONTEXT = cfg.context ? "1" : "0";
|
|
324
336
|
}
|
|
337
|
+
if (cfg.contextHandoff !== undefined && !(env.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim()) {
|
|
338
|
+
env.MNEMOM_AGENT_CONTEXT_HANDOFF = cfg.contextHandoff ? "1" : "0";
|
|
339
|
+
}
|
|
325
340
|
}
|