agent-trellis 0.19.0 → 0.20.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.
Files changed (96) hide show
  1. package/dist/cli.js +12 -1
  2. package/dist/commands/doctor.d.ts +8 -0
  3. package/dist/commands/doctor.js +87 -2
  4. package/dist/commands/evolve.d.ts +43 -0
  5. package/dist/commands/evolve.js +479 -0
  6. package/dist/commands/mcpGateway.d.ts +28 -0
  7. package/dist/commands/mcpGateway.js +40 -6
  8. package/dist/evolve/agentView.d.ts +32 -0
  9. package/dist/evolve/agentView.js +34 -0
  10. package/dist/evolve/apply.d.ts +71 -0
  11. package/dist/evolve/apply.js +162 -0
  12. package/dist/evolve/archive.d.ts +23 -0
  13. package/dist/evolve/archive.js +58 -0
  14. package/dist/evolve/erase.d.ts +17 -0
  15. package/dist/evolve/erase.js +65 -0
  16. package/dist/evolve/extension.d.ts +37 -0
  17. package/dist/evolve/extension.js +63 -0
  18. package/dist/evolve/gate.d.ts +95 -0
  19. package/dist/evolve/gate.js +295 -0
  20. package/dist/evolve/importHistory.d.ts +91 -0
  21. package/dist/evolve/importHistory.js +239 -0
  22. package/dist/evolve/jobMain.d.ts +22 -0
  23. package/dist/evolve/jobMain.js +52 -0
  24. package/dist/evolve/jobRun.d.ts +94 -0
  25. package/dist/evolve/jobRun.js +273 -0
  26. package/dist/evolve/jobShell.d.ts +46 -0
  27. package/dist/evolve/jobShell.js +75 -0
  28. package/dist/evolve/l2.d.ts +93 -0
  29. package/dist/evolve/l2.js +81 -0
  30. package/dist/evolve/lesson.d.ts +57 -0
  31. package/dist/evolve/lesson.js +97 -0
  32. package/dist/evolve/pending.d.ts +69 -0
  33. package/dist/evolve/pending.js +116 -0
  34. package/dist/evolve/probeWorkspace.d.ts +57 -0
  35. package/dist/evolve/probeWorkspace.js +69 -0
  36. package/dist/evolve/propose.d.ts +73 -0
  37. package/dist/evolve/propose.js +136 -0
  38. package/dist/evolve/recorder.d.ts +68 -0
  39. package/dist/evolve/recorder.js +169 -0
  40. package/dist/evolve/report.d.ts +26 -0
  41. package/dist/evolve/report.js +37 -0
  42. package/dist/evolve/reportView.d.ts +96 -0
  43. package/dist/evolve/reportView.js +311 -0
  44. package/dist/evolve/risk.d.ts +106 -0
  45. package/dist/evolve/risk.js +224 -0
  46. package/dist/evolve/sources.d.ts +19 -0
  47. package/dist/evolve/sources.js +29 -0
  48. package/dist/evolve/stateDir.d.ts +21 -0
  49. package/dist/evolve/stateDir.js +56 -0
  50. package/dist/evolve/surface.d.ts +34 -0
  51. package/dist/evolve/surface.js +72 -0
  52. package/dist/evolve/trajectory.d.ts +25 -0
  53. package/dist/evolve/trajectory.js +88 -0
  54. package/dist/evolve/treeToCanonical.d.ts +28 -0
  55. package/dist/evolve/treeToCanonical.js +79 -0
  56. package/dist/evolve/trellisSubstrate.d.ts +65 -0
  57. package/dist/evolve/trellisSubstrate.js +154 -0
  58. package/dist/evolve/trigger.d.ts +177 -0
  59. package/dist/evolve/trigger.js +400 -0
  60. package/dist/evolve/types.d.ts +126 -0
  61. package/dist/evolve/types.js +15 -0
  62. package/dist/evolve/validate.d.ts +139 -0
  63. package/dist/evolve/validate.js +315 -0
  64. package/dist/host/checks.d.ts +57 -0
  65. package/dist/host/checks.js +126 -0
  66. package/dist/host/delivery.d.ts +42 -0
  67. package/dist/host/delivery.js +59 -0
  68. package/dist/host/edits.d.ts +87 -0
  69. package/dist/host/edits.js +102 -0
  70. package/dist/host/hostEvents.d.ts +74 -0
  71. package/dist/host/hostEvents.js +45 -0
  72. package/dist/host/index.d.ts +22 -0
  73. package/dist/host/index.js +17 -0
  74. package/dist/host/levels.d.ts +106 -0
  75. package/dist/host/levels.js +206 -0
  76. package/dist/host/staging.d.ts +56 -0
  77. package/dist/host/staging.js +106 -0
  78. package/dist/host/surface.d.ts +36 -0
  79. package/dist/host/surface.js +20 -0
  80. package/dist/lib/backup.js +12 -3
  81. package/dist/lib/fsAtomic.d.ts +7 -0
  82. package/dist/lib/fsAtomic.js +14 -0
  83. package/dist/lib/hostEventBus.d.ts +47 -0
  84. package/dist/lib/hostEventBus.js +94 -0
  85. package/dist/lib/hostExtensions.d.ts +75 -0
  86. package/dist/lib/hostExtensions.js +196 -0
  87. package/dist/lib/mcpRuntime.d.ts +12 -1
  88. package/dist/lib/mcpRuntime.js +34 -2
  89. package/dist/lib/mcpToolRegistry.d.ts +18 -2
  90. package/dist/lib/mcpToolRegistry.js +29 -9
  91. package/dist/pi-bridge/bundle.js +16 -6
  92. package/docs/magpie-gateway-research.md +422 -0
  93. package/docs/roadmap.md +22 -12
  94. package/docs/runtime-infra.md +41 -22
  95. package/docs/self-evolution.md +138 -44
  96. package/package.json +7 -2
@@ -0,0 +1,94 @@
1
+ /**
2
+ * In-process host event bus (host-extension-points spec).
3
+ *
4
+ * Dispatch is synchronous and ordered: `emit` walks listeners at the event
5
+ * point so a session-end event is observably delivered before upstream
6
+ * release continues. Failures are contained per listener — a synchronous
7
+ * throw or a rejected promise is swallowed and warned, never propagated to
8
+ * the tool call or to sibling listeners. With no subscribers `emit` is a
9
+ * single length check (zero overhead; runtime behavior stays byte-identical
10
+ * to the pre-event world). Listeners are expected to return promptly; slow
11
+ * work belongs behind the returned promise, which the caller never awaits.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { sessionEndEvent, sessionStartEvent, toolCallEvent, } from "../host/hostEvents.js";
15
+ export class HostEventBus {
16
+ warn;
17
+ listeners = [];
18
+ constructor(warn = console.error) {
19
+ this.warn = warn;
20
+ }
21
+ /** Register a listener; returns its unsubscribe function. Registration
22
+ * failure isolation for extensions is the loader's job (8.6) — this method
23
+ * only ever pushes. */
24
+ subscribe(listener) {
25
+ this.listeners.push(listener);
26
+ return () => {
27
+ const index = this.listeners.indexOf(listener);
28
+ if (index >= 0)
29
+ this.listeners.splice(index, 1);
30
+ };
31
+ }
32
+ emit(event) {
33
+ if (this.listeners.length === 0)
34
+ return;
35
+ for (const listener of this.listeners) {
36
+ try {
37
+ const pending = listener(event);
38
+ if (pending && typeof pending.then === "function") {
39
+ void pending.catch((err) => {
40
+ this.warn(`trellis-host-events: subscriber failed: ${err instanceof Error ? err.message : String(err)}`);
41
+ });
42
+ }
43
+ }
44
+ catch (err) {
45
+ this.warn(`trellis-host-events: subscriber failed: ${err instanceof Error ? err.message : String(err)}`);
46
+ }
47
+ }
48
+ }
49
+ get subscriberCount() {
50
+ return this.listeners.length;
51
+ }
52
+ }
53
+ /**
54
+ * Stamps events with process-level identity. One instance per gateway
55
+ * process: `sessionId` is generated here and carried unchanged on every
56
+ * event the process emits ("会话开始"/tool calls/"会话结束").
57
+ */
58
+ export class HostEventEmitter {
59
+ opts;
60
+ sessionId;
61
+ constructor(opts) {
62
+ this.opts = opts;
63
+ this.sessionId = opts.sessionId ?? randomUUID();
64
+ }
65
+ ts() {
66
+ return (this.opts.now ?? Date.now)();
67
+ }
68
+ sessionStart() {
69
+ this.opts.bus.emit(sessionStartEvent({
70
+ ts: this.ts(),
71
+ agent: this.opts.agent,
72
+ session: this.sessionId,
73
+ workspaceRoot: this.opts.workspaceRoot,
74
+ }));
75
+ }
76
+ toolCall(info) {
77
+ this.opts.bus.emit(toolCallEvent({
78
+ ts: this.ts(),
79
+ agent: this.opts.agent,
80
+ session: this.sessionId,
81
+ provider: info.provider,
82
+ tool: info.tool,
83
+ durationMs: info.durationMs,
84
+ result: info.result,
85
+ }));
86
+ }
87
+ sessionEnd() {
88
+ this.opts.bus.emit(sessionEndEvent({
89
+ ts: this.ts(),
90
+ agent: this.opts.agent,
91
+ session: this.sessionId,
92
+ }));
93
+ }
94
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * External host extension loading (host-extension-points spec, 8.6).
3
+ *
4
+ * The ONLY way an external module becomes a Trellis provider or event
5
+ * subscriber: it is declared in the canonical extension manifest at
6
+ * `~/.trellis/mcp/extensions.yaml` (entries carry `id` + a `load` module
7
+ * specifier) and its module exports `activate(ctx)` returning
8
+ * `{ providers?, subscribers? }`. There is no directory scan and no
9
+ * naming convention — a module on disk that the manifest does not name
10
+ * is never imported.
11
+ *
12
+ * Fault isolation is per extension and total: a manifest that fails to
13
+ * parse, an entry of the wrong shape, an import that throws, an
14
+ * `activate` that throws, or an activation of the wrong shape are each
15
+ * recorded as a failure (and warned), and none of them affect the core
16
+ * providers, upstream servers, or any other extension. With no manifest
17
+ * the loader returns empty everything and the gateway is byte-identical
18
+ * to the pre-extension world.
19
+ */
20
+ import type { AgentId } from "../core/types.js";
21
+ import type { HostEventListener } from "./hostEventBus.js";
22
+ import type { TrellisProvider } from "./mcpRuntime.js";
23
+ /** Manifest location relative to the home directory — canonical, not
24
+ * user-home-adjacent: extension declarations are canonical content. */
25
+ export declare const EXTENSIONS_MANIFEST_REL: string;
26
+ export declare function extensionsManifestPath(homeDir: string): string;
27
+ export interface HostExtensionEntry {
28
+ id: string;
29
+ /** Module specifier for dynamic import — an absolute path or a file:
30
+ * URL is the reliable form; bare specifiers resolve by Node's rules
31
+ * from the trellis package itself. */
32
+ load: string;
33
+ }
34
+ /** What an extension's `activate` receives. Metadata only — the extension
35
+ * never gets the bus or registry itself; it RETURNS providers and
36
+ * subscribers and the host wires them through the same registration
37
+ * surface as everything else. */
38
+ export interface HostExtensionContext {
39
+ homeDir: string;
40
+ agent: AgentId;
41
+ workspaceRoot: string | null;
42
+ }
43
+ export interface HostExtensionActivation {
44
+ providers?: TrellisProvider[];
45
+ subscribers?: HostEventListener[];
46
+ }
47
+ export type HostExtensionFailureStage = "manifest" | "invalid" | "import" | "activate";
48
+ export interface HostExtensionFailure {
49
+ /** The manifest entry id, or the manifest itself when it could not be
50
+ * read as an extension list at all. */
51
+ id: string;
52
+ stage: HostExtensionFailureStage;
53
+ message: string;
54
+ }
55
+ export interface LoadedHostExtensions {
56
+ providers: TrellisProvider[];
57
+ subscribers: HostEventListener[];
58
+ failures: HostExtensionFailure[];
59
+ }
60
+ /**
61
+ * Load every extension the canonical manifest declares. Never throws:
62
+ * the worst case is a `failures` entry per broken extension.
63
+ */
64
+ export declare function loadHostExtensions(context: HostExtensionContext, warn?: (message: string) => void): Promise<LoadedHostExtensions>;
65
+ /**
66
+ * 写入(或原位更新)一条清单条目;幂等——同 id 旧条目被替换,其余条目
67
+ * 与未知键原样保留。清单是 canonical 内容,写入是显式管理动作(进化
68
+ * 启用命令 8.1),loader 侧永不写。
69
+ */
70
+ export declare function upsertExtensionEntry(homeDir: string, entry: HostExtensionEntry): void;
71
+ /**
72
+ * 移除一条清单条目;不存在时是无副作用的 no-op(返回 false)。不清空
73
+ * 文件、不动其他条目——清单还可能声明别的扩展。
74
+ */
75
+ export declare function removeExtensionEntry(homeDir: string, id: string): boolean;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * External host extension loading (host-extension-points spec, 8.6).
3
+ *
4
+ * The ONLY way an external module becomes a Trellis provider or event
5
+ * subscriber: it is declared in the canonical extension manifest at
6
+ * `~/.trellis/mcp/extensions.yaml` (entries carry `id` + a `load` module
7
+ * specifier) and its module exports `activate(ctx)` returning
8
+ * `{ providers?, subscribers? }`. There is no directory scan and no
9
+ * naming convention — a module on disk that the manifest does not name
10
+ * is never imported.
11
+ *
12
+ * Fault isolation is per extension and total: a manifest that fails to
13
+ * parse, an entry of the wrong shape, an import that throws, an
14
+ * `activate` that throws, or an activation of the wrong shape are each
15
+ * recorded as a failure (and warned), and none of them affect the core
16
+ * providers, upstream servers, or any other extension. With no manifest
17
+ * the loader returns empty everything and the gateway is byte-identical
18
+ * to the pre-extension world.
19
+ */
20
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
23
+ import { writeFileAtomicSync } from "./fsAtomic.js";
24
+ /** Manifest location relative to the home directory — canonical, not
25
+ * user-home-adjacent: extension declarations are canonical content. */
26
+ export const EXTENSIONS_MANIFEST_REL = join(".trellis", "mcp", "extensions.yaml");
27
+ export function extensionsManifestPath(homeDir) {
28
+ return join(homeDir, EXTENSIONS_MANIFEST_REL);
29
+ }
30
+ function failureMessage(err) {
31
+ return err instanceof Error ? err.message : String(err);
32
+ }
33
+ /** Minimal structural check for one provider handed back by an
34
+ * extension: it must at least be able to answer a call. */
35
+ function isProviderShape(value) {
36
+ return (typeof value === "object" && value !== null &&
37
+ typeof value.id === "string" &&
38
+ typeof value.callTool === "function");
39
+ }
40
+ function isSubscriberShape(value) {
41
+ return typeof value === "function";
42
+ }
43
+ /**
44
+ * Load every extension the canonical manifest declares. Never throws:
45
+ * the worst case is a `failures` entry per broken extension.
46
+ */
47
+ export async function loadHostExtensions(context, warn = console.error) {
48
+ const providers = [];
49
+ const subscribers = [];
50
+ const failures = [];
51
+ const report = (failure) => {
52
+ failures.push(failure);
53
+ warn(`trellis-mcp-gateway: extension "${failure.id}" failed (${failure.stage}): ${failure.message}`);
54
+ };
55
+ const manifestPath = extensionsManifestPath(context.homeDir);
56
+ if (!existsSync(manifestPath)) {
57
+ return { providers, subscribers, failures };
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = (parseYaml(readFileSync(manifestPath, "utf-8")) ?? {});
62
+ }
63
+ catch (err) {
64
+ report({ id: "<manifest>", stage: "manifest", message: failureMessage(err) });
65
+ return { providers, subscribers, failures };
66
+ }
67
+ const rawEntries = parsed.extensions;
68
+ if (rawEntries === undefined || rawEntries === null) {
69
+ return { providers, subscribers, failures };
70
+ }
71
+ if (!Array.isArray(rawEntries)) {
72
+ report({ id: "<manifest>", stage: "manifest", message: "extensions must be a list of {id, load} entries" });
73
+ return { providers, subscribers, failures };
74
+ }
75
+ for (const raw of rawEntries) {
76
+ const entry = raw;
77
+ if (typeof entry !== "object" || entry === null ||
78
+ typeof entry.id !== "string" || entry.id === "" ||
79
+ typeof entry.load !== "string" || entry.load === "") {
80
+ report({ id: "<manifest>", stage: "invalid", message: `entry is not a {id, load} pair: ${JSON.stringify(raw)}` });
81
+ continue;
82
+ }
83
+ let mod;
84
+ try {
85
+ mod = await import(entry.load);
86
+ }
87
+ catch (err) {
88
+ report({ id: entry.id, stage: "import", message: failureMessage(err) });
89
+ continue;
90
+ }
91
+ if (typeof mod.activate !== "function") {
92
+ report({ id: entry.id, stage: "invalid", message: "module does not export an activate function" });
93
+ continue;
94
+ }
95
+ let activation;
96
+ try {
97
+ activation = (await mod.activate(context)) ?? {};
98
+ }
99
+ catch (err) {
100
+ report({ id: entry.id, stage: "activate", message: failureMessage(err) });
101
+ continue;
102
+ }
103
+ if (typeof activation !== "object") {
104
+ report({ id: entry.id, stage: "invalid", message: "activate did not return an object" });
105
+ continue;
106
+ }
107
+ const returnedProviders = Array.isArray(activation.providers) ? activation.providers : [];
108
+ for (const provider of returnedProviders) {
109
+ if (isProviderShape(provider)) {
110
+ providers.push(provider);
111
+ }
112
+ else {
113
+ report({ id: entry.id, stage: "invalid", message: "activation.providers contains a non-provider entry" });
114
+ }
115
+ }
116
+ const returnedSubscribers = Array.isArray(activation.subscribers) ? activation.subscribers : [];
117
+ for (const subscriber of returnedSubscribers) {
118
+ if (isSubscriberShape(subscriber)) {
119
+ subscribers.push(subscriber);
120
+ }
121
+ else {
122
+ report({ id: entry.id, stage: "invalid", message: "activation.subscribers contains a non-function entry" });
123
+ }
124
+ }
125
+ }
126
+ return { providers, subscribers, failures };
127
+ }
128
+ // ---------- 清单写入(声明面的唯一写路径) ----------
129
+ /**
130
+ * 写入(或原位更新)一条清单条目;幂等——同 id 旧条目被替换,其余条目
131
+ * 与未知键原样保留。清单是 canonical 内容,写入是显式管理动作(进化
132
+ * 启用命令 8.1),loader 侧永不写。
133
+ */
134
+ export function upsertExtensionEntry(homeDir, entry) {
135
+ if (typeof entry.id !== "string" || entry.id === "" || typeof entry.load !== "string" || entry.load === "") {
136
+ throw new Error(`extensions.yaml: entry must be a non-empty {id, load} pair, got ${JSON.stringify(entry)}`);
137
+ }
138
+ const manifestPath = extensionsManifestPath(homeDir);
139
+ let parsed = {};
140
+ if (existsSync(manifestPath)) {
141
+ const raw = parseYaml(readFileSync(manifestPath, "utf-8"));
142
+ if (raw !== null && raw !== undefined) {
143
+ if (typeof raw !== "object" || Array.isArray(raw)) {
144
+ throw new Error("extensions.yaml: top level must be a mapping with an `extensions` list");
145
+ }
146
+ parsed = raw;
147
+ }
148
+ }
149
+ const existing = parsed.extensions;
150
+ if (existing !== undefined && !Array.isArray(existing)) {
151
+ // 非数组的 extensions 键是手误产物:抛错而不是静默重置,避免
152
+ // 其余条目无声丢失。
153
+ throw new Error("extensions.yaml: `extensions` must be a list of {id, load} entries");
154
+ }
155
+ const entries = (existing ?? []);
156
+ const next = [];
157
+ let replaced = false;
158
+ for (const raw of entries) {
159
+ const candidate = raw;
160
+ if (candidate !== null && typeof candidate === "object" && candidate.id === entry.id) {
161
+ next.push({ id: entry.id, load: entry.load });
162
+ replaced = true;
163
+ }
164
+ else {
165
+ next.push(raw);
166
+ }
167
+ }
168
+ if (!replaced)
169
+ next.push({ id: entry.id, load: entry.load });
170
+ parsed.extensions = next;
171
+ mkdirSync(dirname(manifestPath), { recursive: true });
172
+ writeFileAtomicSync(manifestPath, stringifyYaml(parsed));
173
+ }
174
+ /**
175
+ * 移除一条清单条目;不存在时是无副作用的 no-op(返回 false)。不清空
176
+ * 文件、不动其他条目——清单还可能声明别的扩展。
177
+ */
178
+ export function removeExtensionEntry(homeDir, id) {
179
+ const manifestPath = extensionsManifestPath(homeDir);
180
+ if (!existsSync(manifestPath))
181
+ return false;
182
+ const raw = parseYaml(readFileSync(manifestPath, "utf-8"));
183
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
184
+ return false;
185
+ const parsed = raw;
186
+ const existing = Array.isArray(parsed.extensions) ? parsed.extensions : [];
187
+ const next = existing.filter((item) => {
188
+ const candidate = item;
189
+ return !(candidate !== null && typeof candidate === "object" && candidate.id === id);
190
+ });
191
+ if (next.length === existing.length)
192
+ return false;
193
+ parsed.extensions = next;
194
+ writeFileAtomicSync(manifestPath, stringifyYaml(parsed));
195
+ return true;
196
+ }
@@ -3,6 +3,7 @@ import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResou
3
3
  import type { AgentId } from "../core/types.js";
4
4
  import type { GatewayBackend } from "./gatewayBackend.js";
5
5
  import type { SkillEntry } from "./skillManifest.js";
6
+ import type { ToolCallInfo } from "../host/hostEvents.js";
6
7
  export interface RuntimeContext {
7
8
  agentId: AgentId;
8
9
  homeDir: string;
@@ -23,14 +24,24 @@ export interface TrellisProvider {
23
24
  export declare class BuiltinRegistry {
24
25
  private readonly providers;
25
26
  private readonly warn;
27
+ /** Event choke point (host-extension-points): called after every routed
28
+ * tool call with metadata only. Absent = pre-event behavior, unchanged. */
29
+ private readonly onToolCall?;
26
30
  private readonly toolOwners;
27
31
  private readonly originalToolOwners;
28
32
  private readonly exposedToOriginal;
29
33
  private readonly resourceOwners;
30
34
  private readonly promptOwners;
31
- constructor(providers: readonly TrellisProvider[], warn?: (message: string) => void);
35
+ constructor(providers: readonly TrellisProvider[], warn?: (message: string) => void,
36
+ /** Event choke point (host-extension-points): called after every routed
37
+ * tool call with metadata only. Absent = pre-event behavior, unchanged. */
38
+ onToolCall?: ((info: ToolCallInfo) => void) | undefined);
32
39
  listTools(context: RuntimeContext): Promise<Tool[]>;
33
40
  callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
41
+ /** Metadata-only event emission at the call choke point. Fully
42
+ * transparent: a failing observer never changes the tool call's result or
43
+ * error semantics (host-events transparency requirement). */
44
+ private emitToolCall;
34
45
  listResources(context: RuntimeContext): Promise<Resource[]>;
35
46
  readResource(uri: URL, context: RuntimeContext): Promise<ReadResourceResult>;
36
47
  listSkills(context: RuntimeContext): Promise<SkillEntry[]>;
@@ -8,17 +8,32 @@ function compactBuiltinToolName(name) {
8
8
  function providerError(provider, error) {
9
9
  return `trellis-mcp-runtime: provider "${provider}" failed: ${error instanceof Error ? error.message : String(error)}`;
10
10
  }
11
+ function resultCategory(result) {
12
+ if (!result?.isError)
13
+ return "ok";
14
+ const text = JSON.stringify(result.content ?? "");
15
+ return /timed? ?out/i.test(text) ? "timeout" : "error";
16
+ }
17
+ function thrownCategory(err) {
18
+ const message = err instanceof Error ? `${err.name} ${err.message}` : String(err);
19
+ return /timed? ?out/i.test(message) ? "timeout" : "error";
20
+ }
11
21
  export class BuiltinRegistry {
12
22
  providers;
13
23
  warn;
24
+ onToolCall;
14
25
  toolOwners = new Map();
15
26
  originalToolOwners = new Map();
16
27
  exposedToOriginal = new Map();
17
28
  resourceOwners = new Map();
18
29
  promptOwners = new Map();
19
- constructor(providers, warn = console.error) {
30
+ constructor(providers, warn = console.error,
31
+ /** Event choke point (host-extension-points): called after every routed
32
+ * tool call with metadata only. Absent = pre-event behavior, unchanged. */
33
+ onToolCall) {
20
34
  this.providers = providers;
21
35
  this.warn = warn;
36
+ this.onToolCall = onToolCall;
22
37
  }
23
38
  async listTools(context) {
24
39
  const candidates = [];
@@ -67,13 +82,30 @@ export class BuiltinRegistry {
67
82
  }
68
83
  if (!provider)
69
84
  return { content: [{ type: "text", text: `unknown Trellis tool "${name}"` }], isError: true };
85
+ const started = Date.now();
70
86
  try {
71
- return await provider.callTool(originalName, args, context);
87
+ const result = await provider.callTool(originalName, args, context);
88
+ this.emitToolCall({ provider: provider.id, tool: name, durationMs: Date.now() - started, result: resultCategory(result) });
89
+ return result;
72
90
  }
73
91
  catch (err) {
92
+ this.emitToolCall({ provider: provider.id, tool: name, durationMs: Date.now() - started, result: thrownCategory(err) });
74
93
  return { content: [{ type: "text", text: providerError(provider.id, err) }], isError: true };
75
94
  }
76
95
  }
96
+ /** Metadata-only event emission at the call choke point. Fully
97
+ * transparent: a failing observer never changes the tool call's result or
98
+ * error semantics (host-events transparency requirement). */
99
+ emitToolCall(info) {
100
+ if (!this.onToolCall)
101
+ return;
102
+ try {
103
+ this.onToolCall(info);
104
+ }
105
+ catch (err) {
106
+ this.warn(`trellis-mcp-runtime: tool-call observer failed: ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }
77
109
  async listResources(context) {
78
110
  const resources = [];
79
111
  this.resourceOwners.clear();
@@ -52,6 +52,17 @@ export declare function prefixedToolName(serverName: string, toolName: string):
52
52
  export interface ToolNameCandidate {
53
53
  serverName: string;
54
54
  toolName: string;
55
+ /**
56
+ * Force the `server__tool` prefix regardless of uniqueness or length.
57
+ * Gateway aggregation sets this on every candidate so an Agent can always
58
+ * read the owning server from the exposed name (mcp-tool-name-preservation:
59
+ * "Prefix every upstream tool with its server name"). A tool whose name
60
+ * already starts with its server's name (e.g. `tanka_memo_search` on
61
+ * `tanka`) is not prefixed again — the name is already source-qualified.
62
+ * Other consumers (e.g. the built-in Runtime registry) leave it unset to
63
+ * keep their own naming contracts.
64
+ */
65
+ alwaysPrefix?: boolean;
55
66
  }
56
67
  /** Allocate all exposed names together so normalization collisions are
57
68
  * deterministic and no upstream tool is silently lost. The returned array
@@ -94,8 +105,13 @@ export declare class McpToolRegistry {
94
105
  replace(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
95
106
  private exposedNameFor;
96
107
  /** Every registered tool, under its prefixed name, in registration
97
- * order. Each tool's own `description`/`inputSchema` and any additional
98
- * fields the upstream sent are preserved verbatim; only `name` changes. */
108
+ * order. Each tool's own `inputSchema` and any additional fields the
109
+ * upstream sent are preserved verbatim; `name` changes, and the
110
+ * description gains a trailing `Source: <server>` line so an Agent can
111
+ * confirm the owning server without relying on the name alone
112
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
113
+ * source"). The upstream's own description text is never altered or
114
+ * removed — the source line is appended. */
99
115
  listTools(): AggregatedTool[];
100
116
  /** The owning upstream and original tool name for a prefixed name, or
101
117
  * `undefined` if nothing owns it. */
@@ -81,12 +81,23 @@ export function allocateExposedToolNames(candidates) {
81
81
  // registry (for example `fixture__env` reaching the Runtime provider).
82
82
  // Do not add a second provider prefix around it.
83
83
  const sourceQualified = normalized.includes("__") || normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
84
+ // Narrower than `sourceQualified`: for forced-prefix candidates the
85
+ // exemption applies only when the tool name already starts with ITS
86
+ // OWN server's name (a second prefix would be redundant, e.g.
87
+ // `tanka__tanka_memo_search`). A tool that merely contains `__`
88
+ // (`b__c`) or gained a `__hash` length bound still needs its server
89
+ // prefix to be identifiable.
90
+ const serverRedundant = normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
84
91
  const unique = (counts.get(candidate.toolName) ?? 0) === 1;
85
- const base = unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH
86
- ? normalized
87
- : unique && sourceQualified
92
+ const base = candidate.alwaysPrefix
93
+ ? serverRedundant
88
94
  ? normalized
89
- : prefixedToolName(candidate.serverName, candidate.toolName);
95
+ : prefixedToolName(candidate.serverName, candidate.toolName)
96
+ : unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH
97
+ ? normalized
98
+ : unique && sourceQualified
99
+ ? normalized
100
+ : prefixedToolName(candidate.serverName, candidate.toolName);
90
101
  let exposed = base;
91
102
  let suffix = 2;
92
103
  while (used.has(exposed)) {
@@ -122,7 +133,7 @@ export class McpToolRegistry {
122
133
  */
123
134
  add(serverName, upstream, tools) {
124
135
  for (const tool of tools)
125
- this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
136
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
126
137
  this.rebuild();
127
138
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
128
139
  return { added, skipped: [] };
@@ -141,7 +152,7 @@ export class McpToolRegistry {
141
152
  this.candidates.length = 0;
142
153
  this.candidates.push(...kept);
143
154
  for (const tool of tools)
144
- this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
155
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
145
156
  this.rebuild();
146
157
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
147
158
  return { added, skipped: [] };
@@ -154,8 +165,13 @@ export class McpToolRegistry {
154
165
  return undefined;
155
166
  }
156
167
  /** Every registered tool, under its prefixed name, in registration
157
- * order. Each tool's own `description`/`inputSchema` and any additional
158
- * fields the upstream sent are preserved verbatim; only `name` changes. */
168
+ * order. Each tool's own `inputSchema` and any additional fields the
169
+ * upstream sent are preserved verbatim; `name` changes, and the
170
+ * description gains a trailing `Source: <server>` line so an Agent can
171
+ * confirm the owning server without relying on the name alone
172
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
173
+ * source"). The upstream's own description text is never altered or
174
+ * removed — the source line is appended. */
159
175
  listTools() {
160
176
  this.rebuild();
161
177
  return this.order.map((name) => {
@@ -163,7 +179,11 @@ export class McpToolRegistry {
163
179
  const title = name === entry.toolName || entry.tool.title !== undefined
164
180
  ? entry.tool.title
165
181
  : `${entry.serverName} / ${entry.toolName}`;
166
- return { ...entry.tool, name, ...(title === undefined ? {} : { title }) };
182
+ const sourceLine = `Source: ${entry.serverName}`;
183
+ const description = entry.tool.description
184
+ ? `${entry.tool.description}\n\n${sourceLine}`
185
+ : sourceLine;
186
+ return { ...entry.tool, name, description, ...(title === undefined ? {} : { title }) };
167
187
  });
168
188
  }
169
189
  /** The owning upstream and original tool name for a prefixed name, or
@@ -46008,8 +46008,9 @@ function allocateExposedToolNames(candidates) {
46008
46008
  const normalized = normalizedToolName(candidate.toolName);
46009
46009
  const source = compactSourceSegment(candidate.serverName);
46010
46010
  const sourceQualified = normalized.includes("__") || normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
46011
+ const serverRedundant = normalized === source || normalized.startsWith(`${source}_`) || normalized.startsWith(`${source}__`);
46011
46012
  const unique = (counts.get(candidate.toolName) ?? 0) === 1;
46012
- const base = unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH ? normalized : unique && sourceQualified ? normalized : prefixedToolName(candidate.serverName, candidate.toolName);
46013
+ const base = candidate.alwaysPrefix ? serverRedundant ? normalized : prefixedToolName(candidate.serverName, candidate.toolName) : unique && normalized.length > SHORT_EXPOSED_TOOL_NAME_LENGTH ? normalized : unique && sourceQualified ? normalized : prefixedToolName(candidate.serverName, candidate.toolName);
46013
46014
  let exposed = base;
46014
46015
  let suffix = 2;
46015
46016
  while (used.has(exposed)) {
@@ -46042,7 +46043,7 @@ var McpToolRegistry = class {
46042
46043
  * aggregated, with no special-casing here.
46043
46044
  */
46044
46045
  add(serverName, upstream, tools) {
46045
- for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
46046
+ for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
46046
46047
  this.rebuild();
46047
46048
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
46048
46049
  return { added, skipped: [] };
@@ -46060,7 +46061,7 @@ var McpToolRegistry = class {
46060
46061
  const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
46061
46062
  this.candidates.length = 0;
46062
46063
  this.candidates.push(...kept);
46063
- for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
46064
+ for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool, alwaysPrefix: true });
46064
46065
  this.rebuild();
46065
46066
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
46066
46067
  return { added, skipped: [] };
@@ -46072,14 +46073,23 @@ var McpToolRegistry = class {
46072
46073
  return void 0;
46073
46074
  }
46074
46075
  /** Every registered tool, under its prefixed name, in registration
46075
- * order. Each tool's own `description`/`inputSchema` and any additional
46076
- * fields the upstream sent are preserved verbatim; only `name` changes. */
46076
+ * order. Each tool's own `inputSchema` and any additional fields the
46077
+ * upstream sent are preserved verbatim; `name` changes, and the
46078
+ * description gains a trailing `Source: <server>` line so an Agent can
46079
+ * confirm the owning server without relying on the name alone
46080
+ * (mcp-gateway-hosting: "Resold tool metadata identifies its upstream
46081
+ * source"). The upstream's own description text is never altered or
46082
+ * removed — the source line is appended. */
46077
46083
  listTools() {
46078
46084
  this.rebuild();
46079
46085
  return this.order.map((name) => {
46080
46086
  const entry = this.entries.get(name);
46081
46087
  const title = name === entry.toolName || entry.tool.title !== void 0 ? entry.tool.title : `${entry.serverName} / ${entry.toolName}`;
46082
- return { ...entry.tool, name, ...title === void 0 ? {} : { title } };
46088
+ const sourceLine = `Source: ${entry.serverName}`;
46089
+ const description = entry.tool.description ? `${entry.tool.description}
46090
+
46091
+ ${sourceLine}` : sourceLine;
46092
+ return { ...entry.tool, name, description, ...title === void 0 ? {} : { title } };
46083
46093
  });
46084
46094
  }
46085
46095
  /** The owning upstream and original tool name for a prefixed name, or