-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathruntime.ts
More file actions
2245 lines (2056 loc) · 87.3 KB
/
Copy pathruntime.ts
File metadata and controls
2245 lines (2056 loc) · 87.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { LanguageModelV3, LanguageModelV3Message } from "@ai-sdk/provider";
import { NoopEventSink } from "../adapters/noop-events.ts";
import { WorkspaceLogSink } from "../adapters/workspace-log-sink.ts";
import { BundleLifecycleManager } from "../bundles/lifecycle.ts";
import { deriveServerName } from "../bundles/paths.ts";
import { setConnectionRunningHandler } from "../bundles/pending-auth-buffer.ts";
import type { AppInfo, BundleInstance } from "../bundles/types.ts";
import { log } from "../cli/log.ts";
import { isToolVisibleToRole, type ResolvedFeatures, resolveFeatures } from "../config/features.ts";
import { deriveOverridePath } from "../config/overrides.ts";
import { createPrivilegeHook, NoopConfirmationGate } from "../config/privilege.ts";
import { generateTitle } from "../conversation/auto-title.ts";
import { EventSourcedConversationStore } from "../conversation/event-sourced-store.ts";
import { JsonlConversationStore } from "../conversation/jsonl-store.ts";
import { InMemoryConversationStore } from "../conversation/memory-store.ts";
import type {
ConversationListResult,
ConversationStore,
ParticipantInfo,
} from "../conversation/types.ts";
import { sliceHistory, windowMessages } from "../conversation/window.ts";
import { AgentEngine } from "../engine/engine.ts";
import type {
ContextAssembledPayload,
ContextAssembledSource,
EngineConfig,
EngineEvent,
EngineHooks,
EventSink,
SkillsLoadedPayload,
ToolSchema,
} from "../engine/types.ts";
import { rehydrateUserResources } from "../files/rehydrate.ts";
import { createFileStore } from "../files/store.ts";
import { DEFAULT_FILE_CONFIG, type FileConfig } from "../files/types.ts";
import type { InstanceConfig } from "../identity/instance.ts";
import { loadInstanceConfig } from "../identity/instance.ts";
import type { IdentityProvider, UserIdentity } from "../identity/provider.ts";
import { createIdentityProvider } from "../identity/provider.ts";
import { DEV_IDENTITY } from "../identity/providers/dev.ts";
import { UserStore } from "../identity/user.ts";
import { InstructionsStore } from "../instructions/index.ts";
import { buildModelResolver, resolveModelString } from "../model/registry.ts";
import { PermissionStore } from "../permissions/permission-store.ts";
import type { Layer3SkillEntry, PromptAppInfo } from "../prompt/compose.ts";
import { composeSystemPrompt } from "../prompt/compose.ts";
import { ConnectorDirectory } from "../registries/directory.ts";
import { RegistryStore } from "../registries/registry-store.ts";
import {
loadBuiltinSkills,
loadCoreSkills,
loadScopedSkills,
loadSkillDir,
mergeScopedSkills,
partitionSkills,
} from "../skills/loader.ts";
import { SkillMatcher } from "../skills/matcher.ts";
import { selectLayer3Skills } from "../skills/select.ts";
import { approxTokens } from "../skills/tokens.ts";
import type { Skill } from "../skills/types.ts";
import { TelemetryManager } from "../telemetry/manager.ts";
import { PostHogEventSink } from "../telemetry/posthog-sink.ts";
import type { DelegateContext } from "../tools/delegate.ts";
import { McpSource } from "../tools/mcp-source.ts";
import type { ToolRegistry } from "../tools/registry.ts";
import { createSystemTools } from "../tools/system-tools.ts";
import type { ResourceData } from "../tools/types.ts";
import { UserConnectorStore } from "../users/user-connector-store.ts";
import { WorkspaceStore } from "../workspace/workspace-store.ts";
import { RunInProgressError } from "./errors.ts";
import { PlacementRegistry } from "./placement-registry.ts";
import {
getRequestContext,
type RequestContext,
runWithRequestContext,
} from "./request-context.ts";
import { buildSkillsLoadedPayload } from "./skills-loaded-payload.ts";
import { surfaceTools } from "./tools.ts";
import type { ChatRequest, ChatResult, ModelSlots, RuntimeConfig, TurnUsage } from "./types.ts";
import { createWorkspaceRegistry, startWorkspaceBundles } from "./workspace-runtime.ts";
const DEFAULT_WORK_DIR = join(homedir(), ".nimblebrain");
const DEFAULT_MODEL = "claude-sonnet-4-6";
import { DEFAULT_MAX_INPUT_TOKENS, DEFAULT_MAX_ITERATIONS } from "../limits.ts";
import { resolveMaxOutputTokens } from "./resolve-max-output-tokens.ts";
import { resolveThinking } from "./resolve-thinking.ts";
const DEFAULT_MAX_HISTORY_MESSAGES = 40;
/** Known model slot names. */
const MODEL_SLOTS = ["default", "fast", "reasoning"] as const;
type ModelSlot = (typeof MODEL_SLOTS)[number];
const ALIAS_PREFIX = "alias:";
/** Check if a string is an alias reference (e.g., "alias:fast"). */
function isAliasRef(s: string): boolean {
return s.startsWith(ALIAS_PREFIX);
}
/** Extract the slot name from an alias reference. Returns null if not a valid slot. */
function parseAliasRef(s: string): ModelSlot | null {
if (!isAliasRef(s)) return null;
const slot = s.slice(ALIAS_PREFIX.length);
return MODEL_SLOTS.includes(slot as ModelSlot) ? (slot as ModelSlot) : null;
}
function resolveWorkDir(config: RuntimeConfig): string {
return config.workDir ?? DEFAULT_WORK_DIR;
}
function globalSkillDir(config: RuntimeConfig): string {
return join(resolveWorkDir(config), "skills");
}
/** Multi-event sink that fans out to multiple sinks. */
class MultiEventSink implements EventSink {
constructor(private sinks: EventSink[]) {}
emit(event: EngineEvent): void {
for (const sink of this.sinks) sink.emit(event);
}
}
/**
* Tracks parent engine run state for delegate context.
* Listens to engine events to maintain current runId and iteration count.
*/
class DelegateTracker implements EventSink {
private currentRunId = "";
private currentIteration = 0;
private maxIterations = 10;
emit(event: EngineEvent): void {
if (event.type === "run.start") {
// Only track top-level runs (no parentRunId)
if (!event.data.parentRunId) {
this.currentRunId = event.data.runId as string;
this.maxIterations = event.data.maxIterations as number;
this.currentIteration = 0;
}
} else if (event.type === "llm.done") {
// Only track top-level LLM calls (no parentRunId)
if (!event.data.parentRunId) {
this.currentIteration++;
}
}
}
getParentRunId(): string {
return this.currentRunId;
}
getRemainingIterations(): number {
return this.maxIterations - this.currentIteration;
}
}
export class Runtime {
private resolveModelFn: (modelString: string) => LanguageModelV3;
private store: ConversationStore;
private skillMatcher: SkillMatcher;
private config: RuntimeConfig;
private contextSkills: Skill[];
private eventStore: EventSourcedConversationStore | null;
private hooks: EngineHooks;
private defaultEvents: EventSink;
private lifecycle: BundleLifecycleManager;
private placementRegistry: PlacementRegistry;
private telemetryManager: TelemetryManager;
private _features: ResolvedFeatures;
private _internalToken: string;
private _instanceConfig: InstanceConfig | null;
private _userStore: UserStore;
private _workspaceStore: WorkspaceStore;
private _userConnectorStore: UserConnectorStore | null = null;
private _permissionStore: PermissionStore | null = null;
private _registryStore: RegistryStore | null = null;
private _identityProvider: IdentityProvider | null;
/** Getter for the current request identity — reads from AsyncLocalStorage. */
_getIdentity: () => UserIdentity | null = () => null;
/** Getter for the current request workspace ID — reads from AsyncLocalStorage. */
_getWorkspaceId: () => string | null = () => null;
/** Per-workspace ToolRegistry instances — each workspace gets its own scoped registry. */
private _workspaceRegistries: Map<string, ToolRegistry>;
// Protected sources are captured in start() and passed to startWorkspaceBundles directly.
/** The system source ("nb") — shared across workspace registries. */
_systemSource: import("../tools/types.ts").ToolSource | null;
/** Platform sources (home, conversations, files, etc.) — retained for JIT workspace registration. */
private _platformSources: import("../tools/types.ts").ToolSource[] = [];
/**
* Boot-time bundle startup failures recorded by `startWorkspaceBundles`.
* These never produced an McpSource; HealthMonitor reads this list at
* construction so `/v1/health` reports the failures as terminal `dead`
* entries rather than silently omitting them.
*/
private _bundleStartFailures: import("./workspace-runtime.ts").BundleStartFailure[] = [];
/**
* Domain-context getter for the automations bundle. Set by the
* automations source factory; consumed by internal callers (CLI's
* `nb automation pause/resume`, bundle lifecycle's
* `installBundleSchedules` / `removeBundleAutomations`) that need the
* full domain shape — including operator-only fields (`source`,
* `bundleName`, `allowedTools`) — that the LLM-facing tool schema
* deliberately doesn't expose. See `src/tools/platform/CLAUDE.md` § 1.4.
*/
private _automationsContextGetter:
| (() => import("../bundles/automations/src/domain.ts").AutomationDomainContext)
| null = null;
/** Getter for current workspace ID (set per-request). */
private _currentWorkspaceId: (() => string | null) | null = null;
private _manageConversationCtx:
| import("../tools/conversation-tools.ts").ManageConversationContext
| null = null;
private skillResourceCache = new Map<string, { content: string; fetchedAt: number }>();
private static readonly SKILL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Conversation IDs with an in-flight chat() call. Prevents concurrent runs on
* the same conversation.
*
* Scope: single-process / single-pod. Correct today because each tenant runs
* with `platform.replicas: 1` — all chat traffic for a conversation lands on
* the same Runtime instance. If a tenant is ever scaled to multiple replicas,
* this lock stops being authoritative (concurrent requests can land on
* different pods) and this invariant needs to move to a shared store. The
* conversation JSONL on the shared PVC has the same single-writer assumption,
* so the two would need to be addressed together.
*/
private readonly activeConversations = new Set<string>();
private constructor(
_engine: AgentEngine,
resolveModelFn: (modelString: string) => LanguageModelV3,
store: ConversationStore,
skillMatcher: SkillMatcher,
config: RuntimeConfig,
contextSkills: Skill[],
eventStore: EventSourcedConversationStore | null,
hooks: EngineHooks,
defaultEvents: EventSink,
lifecycle: BundleLifecycleManager,
placementRegistry: PlacementRegistry,
telemetryManager: TelemetryManager,
features: ResolvedFeatures,
internalToken: string,
instanceConfig: InstanceConfig | null,
userStore: UserStore,
workspaceStore: WorkspaceStore,
identityProvider: IdentityProvider | null,
workspaceRegistries: Map<string, ToolRegistry>,
systemSource: import("../tools/types.ts").ToolSource | null,
currentWorkspaceId: () => string | null,
) {
this.resolveModelFn = resolveModelFn;
this.store = store;
this.skillMatcher = skillMatcher;
this.config = config;
this.contextSkills = contextSkills;
this.eventStore = eventStore;
this.hooks = hooks;
this.defaultEvents = defaultEvents;
this.lifecycle = lifecycle;
this.placementRegistry = placementRegistry;
this.telemetryManager = telemetryManager;
this._features = features;
this._internalToken = internalToken;
this._instanceConfig = instanceConfig;
this._userStore = userStore;
this._workspaceStore = workspaceStore;
this._identityProvider = identityProvider;
this._workspaceRegistries = workspaceRegistries;
this._systemSource = systemSource;
this._currentWorkspaceId = currentWorkspaceId;
}
/** Create and start a runtime from config. */
static async start(config: RuntimeConfig): Promise<Runtime> {
// Derive the override-file path when the caller supplied a configPath
// but not an explicit override path. The CLI's loadConfig already
// populates both; this fallback covers embedded callers (tests,
// library use) that build a RuntimeConfig directly.
if (config.configPath && !config.configOverridePath) {
config = { ...config, configOverridePath: deriveOverridePath(config.configPath) };
}
const resolveModelFn = resolveModel(config);
const telemetryManager = TelemetryManager.create({
workDir: resolveWorkDir(config),
enabled: config.telemetry?.enabled,
mode: "serve",
});
// Load identity stores early — before bundle startup
const workDir = resolveWorkDir(config);
const instanceConfig = await loadInstanceConfig(workDir);
const userStore = new UserStore(workDir);
const workspaceStore = new WorkspaceStore(workDir);
const identityProvider = createIdentityProvider(instanceConfig, userStore, workspaceStore);
const { events: baseEvents, eventStore } = buildEventSink(config);
// Create delegate tracker and include it in the event pipeline
const delegateTracker = new DelegateTracker();
const sinkList: EventSink[] = [baseEvents, delegateTracker];
if (eventStore) {
sinkList.push(eventStore);
}
if (telemetryManager.isEnabled()) {
sinkList.push(new PostHogEventSink(telemetryManager));
}
const events: EventSink = new MultiEventSink(sinkList);
// Mint a scoped internal token for protected default bundles.
// Rotated on every runtime restart — never persisted.
const internalToken = crypto.randomUUID();
initWorkDir(config);
// Create placement registry and lifecycle manager
const placementRegistry = new PlacementRegistry();
const mpakHome = join(resolve(resolveWorkDir(config)), "apps");
const lifecycle = new BundleLifecycleManager(
events,
config.configPath,
config.allowInsecureRemotes,
mpakHome,
);
lifecycle.setPlacementRegistry(placementRegistry);
// Wire the connection-running notification path so URL bundles
// whose interactive OAuth completes (after the user clicks Connect
// and returns from the AS) transition out of `pending_auth` and
// emit the `connection.state_changed` SSE event for the UI.
setConnectionRunningHandler((wsId, serverName) => {
lifecycle.recordConnectionStateChange(serverName, wsId, "_workspace", "running");
});
const gate = config.confirmationGate ?? new NoopConfirmationGate();
const maxInputTokens = config.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
const maxHistoryMessages = config.maxHistoryMessages ?? DEFAULT_MAX_HISTORY_MESSAGES;
// Build delegate context for nb__delegate tool
// Use a late-bound getter for defaultModel so it reflects live config changes
const getDefaultModel = () => {
const models = config.models;
return models?.default ?? config.defaultModel ?? DEFAULT_MODEL;
};
const resolveSlot = (s: string): string => {
const slot = parseAliasRef(s);
if (!slot) return s;
const models = config.models;
const fallback = config.defaultModel ?? DEFAULT_MODEL;
const slots: ModelSlots = {
default: models?.default ?? fallback,
fast: models?.fast ?? fallback,
reasoning: models?.reasoning ?? fallback,
};
return slots[slot];
};
const delegateCtx: DelegateContext = {
resolveModel: resolveModelFn,
resolveSlot,
get tools() {
if (!rtHolder.rt) throw new Error("Runtime not initialized");
return rtHolder.rt.getRegistryForCurrentWorkspace();
},
events,
// Use getter so workspace agents override instance agents per-request.
// Workspace agents merge over (not replace) instance agents.
// Prefers AsyncLocalStorage context for concurrency safety.
get agents() {
const wsAgents = getRequestContext()?.workspaceAgents ?? null;
if (wsAgents) {
return { ...(config.agents ?? {}), ...wsAgents };
}
return config.agents;
},
getRemainingIterations: () => delegateTracker.getRemainingIterations(),
getParentRunId: () => delegateTracker.getParentRunId(),
defaultModel: getDefaultModel(),
defaultMaxInputTokens: maxInputTokens,
// Raw operator config (may be undefined). Delegate resolves against
// the child's model at execution time so the resolved values fit
// the child's model rather than the parent's.
configMaxOutputTokens: config.maxOutputTokens,
configThinking: config.thinking,
configThinkingBudgetTokens: config.thinkingBudgetTokens,
};
// System tools (search, manage_app, bundle_status, delegate). Skill
// mutation lives in the dedicated `nb__skills` source — registered
// separately via `createPlatformSources`.
// Use a late-bound holder so reloadSkills can reference `rt` after construction.
const rtHolder: { rt?: Runtime } = {};
const boundReloadSkills = async () => {
if (rtHolder.rt) await rtHolder.rt.reloadSkills();
};
const skillDirPath = globalSkillDir(config);
const boundGetSkills = () => {
const rt = rtHolder.rt;
return {
context: rt ? rt.getContextSkills() : [],
matchable: rt ? rt.getMatchableSkills() : [],
};
};
const features = resolveFeatures(config.features);
const hooks: EngineHooks = {
beforeToolCall: createPrivilegeHook(gate, events, features),
transformContext: (messages) => {
const sliced = sliceHistory(messages, maxHistoryMessages);
return windowMessages(sliced, maxInputTokens);
},
};
const store = buildStore(config);
const { contextSkills, skillMatcher } = buildSkills(config);
const defaultModelId = getDefaultModel();
// Workspace-aware ToolRouter proxy: the engine calls availableTools()/execute()
// within runWithRequestContext(), so the proxy reads the current workspace's registry.
const workspaceToolRouter: import("../engine/types.ts").ToolRouter = {
availableTools: () => {
if (!rtHolder.rt) throw new Error("Runtime not initialized");
return rtHolder.rt.getRegistryForCurrentWorkspace().availableTools();
},
execute: (call) => {
if (!rtHolder.rt) throw new Error("Runtime not initialized");
return rtHolder.rt.getRegistryForCurrentWorkspace().execute(call);
},
};
const engine = new AgentEngine(resolveModelFn(defaultModelId), workspaceToolRouter, events);
// Request-scoped context — all identity/workspace reads go through AsyncLocalStorage.
// Set via runWithRequestContext() in chat(), handleToolCall(), and MCP handler.
const getIdentity = (): UserIdentity | null => getRequestContext()?.identity ?? null;
const getWorkspaceId = (): string | null => getRequestContext()?.workspaceId ?? null;
// Build management tool contexts using the identity holder + stores from task 001
// ManageUsersContext is always created. In dev mode (no identity provider),
// the tool can still list/update/delete users — it just can't create
// users with API keys (that requires a provider with credential login).
const manageUsersCtx = { getIdentity, userStore, provider: identityProvider };
const manageWorkspacesCtx = { getIdentity, workspaceStore };
const manageMembersCtx = { getIdentity, workspaceStore, userStore };
const manageConversationCtx: import("../tools/conversation-tools.ts").ManageConversationContext =
{
getIdentity,
conversationStore: store,
workspaceStore,
};
const manageBundleCtx = {
getWorkspaceId,
workspaceStore,
workDir: resolveWorkDir(config),
configDir: config.configPath ? dirname(config.configPath) : undefined,
allowInsecureRemotes: config.allowInsecureRemotes,
// The runtime sink flows into every McpSource spawned by manage_app
// install/configure. Keeps chat-initiated bundle installs on the same
// live-update pipeline as boot-time bundle startup.
eventSink: events,
};
// Create Runtime with empty workspace registries first — needed by system tools
const rt = new Runtime(
engine,
resolveModelFn,
store,
skillMatcher,
config,
contextSkills,
eventStore,
hooks,
events,
lifecycle,
placementRegistry,
telemetryManager,
features,
internalToken,
instanceConfig,
userStore,
workspaceStore,
identityProvider,
new Map<string, ToolRegistry>(),
null, // systemSource — set after creation
getWorkspaceId,
);
rtHolder.rt = rt;
rt._getIdentity = getIdentity;
rt._getWorkspaceId = getWorkspaceId;
rt._manageConversationCtx = manageConversationCtx;
// Register the `nb` system source. Built as an in-process MCP server
// — `createSystemTools` returns it already-started so it's ready to
// serve tools and resources to every workspace registry.
const systemTools = await createSystemTools(
() => rt.getRegistryForCurrentWorkspace(),
config.configPath,
gate,
lifecycle,
delegateCtx,
skillDirPath,
boundReloadSkills,
boundGetSkills,
events,
features,
rt,
mpakHome,
manageUsersCtx,
manageWorkspacesCtx,
manageMembersCtx,
manageConversationCtx,
manageBundleCtx,
);
rt._systemSource = systemTools;
// Phase 2: Create platform capability sources. Each is an in-process
// MCP server reachable through `InMemoryTransport` — no subprocess.
// `createPlatformSources` returns sources already started.
//
// The automations source registers its domain-context getter on `rt`
// during construction (rt.registerAutomationsContext). We forward the
// getter to the lifecycle manager so bundle-contributed schedules
// can be created/removed via the domain API directly — bypassing the
// LLM-facing tool surface (which doesn't accept `source: "bundle"`
// or `bundleName`). See src/tools/platform/CLAUDE.md § 1.4.
const { createPlatformSources } = await import("../tools/platform/index.ts");
const platformSources = await createPlatformSources(rt, events);
if (rt._automationsContextGetter) {
lifecycle.setAutomationsContextGetter(rt._automationsContextGetter);
}
// Register placements declared by platform sources. The helper isolates
// the duck-type — `getPlacements()` is on `McpSource` (carrying the
// declarations from `defineInProcessApp`) but isn't on the `ToolSource`
// interface itself.
for (const src of platformSources) {
const placements = readSourcePlacements(src);
if (placements.length > 0) {
placementRegistry.register(src.name, placements);
}
}
// Phase 3: Start workspace bundles with per-workspace registries
const configDir = config.configPath ? dirname(config.configPath) : undefined;
const {
registries: workspaceRegistries,
entries: workspaceBundleEntries,
failures: bundleStartFailures,
} = await startWorkspaceBundles(
workspaceStore,
platformSources,
systemTools,
events,
configDir,
{
workDir: resolveWorkDir(config),
allowInsecureRemotes: config.allowInsecureRemotes,
},
);
rt._workspaceRegistries = workspaceRegistries;
rt._platformSources = platformSources;
rt._bundleStartFailures = bundleStartFailures;
// Wire the workspace registries into lifecycle so workspace-scope
// startAuth / disconnect / install can add+remove sources without
// each route having to thread the registry through.
lifecycle.setWorkspaceRegistries(workspaceRegistries);
// User-scope install needs to know which workspaces a user is in
// to register their personal bundles into each workspace's tool
// registry. Closure-based to avoid an import cycle (lifecycle ←→
// workspaceStore would be circular).
lifecycle.setWorkspacesForUserResolver(async (userId: string) => {
const all = await workspaceStore.getWorkspacesForUser(userId);
return all.map((ws) => ws.id);
});
// Seed lifecycle instances for workspace bundles (user-installed only)
for (const entry of workspaceBundleEntries) {
const { serverName: sn, bundle: ref, meta, wsId, dataDir } = entry;
const label = "name" in ref ? ref.name : "url" in ref ? ref.url : ref.path;
// Pass the per-workspace registry so seedInstance can register a
// UserPoolSource for user-scope URL bundles (used in workspace.json
// for the legacy "member" path; new user-scope installs flow
// through user.json + seedUserInstance below).
const wsRegistry = workspaceRegistries.get(wsId);
lifecycle.seedInstance(sn, label, ref, meta ?? undefined, wsId, dataDir, wsRegistry);
const instance = lifecycle.getInstance(sn, wsId);
if (instance?.ui?.placements && instance.ui.placements.length > 0) {
placementRegistry.register(sn, instance.ui.placements, wsId);
}
}
// Boot pass for user-scope personal connections. Each user.json
// declares the bundles that user has personally installed; for each
// (user, bundle) pair we seed a user-scope BundleInstance and wire
// a UserPoolSource into every workspace registry the user is in.
// Errors here are isolated per-record so a single corrupt user.json
// doesn't prevent boot.
const userConnStore = rt.getUserConnectorStore();
const allUsers = await userConnStore.list();
for (const userRecord of allUsers) {
for (const ref of userRecord.bundles) {
if (!("url" in ref)) continue;
const sn =
ref.serverName ?? new URL(ref.url).hostname.replace(/[^a-z0-9]/gi, "-").toLowerCase();
try {
await lifecycle.seedUserInstance(sn, ref, userRecord.userId);
} catch (err) {
process.stderr.write(
`[runtime] Failed to seed user-scope "${sn}" for ${userRecord.userId}: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}
}
return rt;
}
/** True if a chat() is currently in flight on this conversation. */
isConversationActive(conversationId: string): boolean {
return this.activeConversations.has(conversationId);
}
/** Process a chat message. Optional per-request EventSink for SSE streaming. */
async chat(request: ChatRequest, requestSink?: EventSink): Promise<ChatResult> {
const lockedConvId = request.conversationId;
if (lockedConvId && this.activeConversations.has(lockedConvId)) {
throw new RunInProgressError(lockedConvId);
}
if (lockedConvId) this.activeConversations.add(lockedConvId);
try {
return await this._chatInner(request, requestSink);
} finally {
if (lockedConvId) this.activeConversations.delete(lockedConvId);
}
}
private async _chatInner(request: ChatRequest, requestSink?: EventSink): Promise<ChatResult> {
if (!request.workspaceId) {
throw new Error("workspaceId is required. Every chat request must be workspace-scoped.");
}
const wsId = request.workspaceId;
// Resolve conversation store: always workspace-scoped.
// JsonlConversationStore is stateless (each operation reads from disk),
// so per-request instances are safe.
const workDir = resolveWorkDir(this.config);
const wsConvDir = join(workDir, "workspaces", wsId, "conversations");
const store: ConversationStore = new EventSourcedConversationStore({
dir: wsConvDir,
logLevel: this.config.logging?.level ?? "normal",
});
// Load workspace config once per request for agents/models overrides.
const workspace = await this._workspaceStore.get(wsId);
const createOpts = {
ownerId: request.identity?.id,
workspaceId: wsId,
...(request.metadata ? { metadata: request.metadata } : {}),
};
const conversation = request.conversationId
? ((await store.load(request.conversationId)) ?? (await store.create(createOpts)))
: await store.create(createOpts);
// Preserve metadata on resumed conversations (don't overwrite)
if (request.metadata && !conversation.metadata) {
conversation.metadata = request.metadata;
}
// Build user message content: text + MCP `resource_link` blocks for
// attachments. Bytes for binary attachments live in the workspace
// FileStore (already persisted by `ingestFiles`); the conversation log
// carries only the URI. The runtime rehydrates image links to AI SDK
// `file` parts at the `model.doStream` boundary — see `rehydrateUserResources`.
type TextPart = { type: "text"; text: string };
type ResourceLinkPart = {
type: "resource_link";
uri: string;
mimeType: string;
name: string;
};
const userContent: Array<TextPart | ResourceLinkPart> = [];
if (request.message) {
userContent.push({ type: "text", text: request.message });
}
if (request.contentParts?.length) {
for (const part of request.contentParts) {
if (part.type === "text") {
userContent.push({ type: "text", text: part.text });
} else if (part.type === "resource_link") {
userContent.push({
type: "resource_link",
uri: part.uri,
mimeType: part.mimeType,
name: part.name,
});
}
}
}
// Ensure content is never empty — file-only uploads may have no text message
if (userContent.length === 0) {
const filenames = request.fileRefs?.map((f) => f.filename).join(", ") || "files";
userContent.push({ type: "text", text: `[Uploaded: ${filenames}]` });
}
await store.append(conversation, {
role: "user",
content: userContent,
timestamp: new Date().toISOString(),
...(request.identity?.id ? { userId: request.identity.id } : {}),
...(request.fileRefs?.length ? { metadata: { files: request.fileRefs } } : {}),
});
let skill = this.skillMatcher.match(request.message);
// Dependency checking: warn if a matched skill requires bundles that aren't installed
if (skill?.manifest.requiresBundles?.length) {
const missing: string[] = [];
for (const bundleName of skill.manifest.requiresBundles) {
const serverName = deriveServerName(bundleName);
if (!this.lifecycle?.getInstance(serverName, wsId)) {
missing.push(bundleName);
}
}
if (missing.length > 0) {
skill = {
...skill,
body:
skill.body +
`\n\n⚠️ Missing dependencies: ${missing.join(", ")}. Some capabilities may be unavailable. Install with nb__manage_app.`,
};
}
}
// `buildAppsList` populates each app's `customInstructions` from the
// bundle's `app://instructions` resource (when published);
// org and workspace overlays come from platform-owned storage.
const apps = await this.buildAppsList(wsId);
const liveOverlays = await this.readPromptOverlays(wsId);
// Workspace-scoped registry for this request
const activeRegistry = this.getRegistryForWorkspace(wsId);
// Build focusedApp when the request is scoped to a specific app (§7 app-aware chat)
let focusedApp: import("../prompt/compose.ts").FocusedAppInfo | undefined;
if (request.appContext) {
const source = activeRegistry
.getSources()
.find((s) => s.name === request.appContext?.serverName);
if (source) {
try {
const sourceTools = await source.tools();
const skillResource = await this.getAppSkillResource(request.appContext.serverName);
const referenceUri = `skill://${request.appContext.serverName}/reference`;
const hasReference = skillResource
? source instanceof McpSource && (await this.hasResource(source, referenceUri))
: false;
const bundleInstance = this.lifecycle?.getInstance(request.appContext.serverName, wsId);
focusedApp = {
name: request.appContext.appName,
tools: sourceTools.map((t) => ({
name: t.name,
description: t.description,
})),
...(skillResource ? { skillResource } : {}),
...(hasReference ? { referenceResourceUri: referenceUri } : {}),
trustScore: bundleInstance?.trustScore ?? 100,
};
} catch {
// Source may be stopped or crashed — skip silently
}
}
}
// Build appState for prompt injection (Synapse Feature 2 — LLM-aware UI state)
let appState: import("../prompt/compose.ts").AppStateInfo | undefined;
if (request.appContext?.appState && focusedApp) {
const bundleRef = this.lifecycle?.getInstance(request.appContext.serverName, wsId);
appState = {
state: request.appContext.appState.state,
summary: request.appContext.appState.summary,
updatedAt: request.appContext.appState.updatedAt,
trustScore: bundleRef?.trustScore ?? 100,
};
}
const allTools = (await activeRegistry.availableTools()).filter((t) =>
isToolVisibleToRole(t.name, request.identity?.orgRole),
);
const { direct: tools, proxied } = surfaceTools(allTools, skill, {
focusedServerName: request.appContext?.serverName,
requestAllowedTools: request.allowedTools,
});
// Per-user preferences from the authenticated identity
const reqIdentity = request.identity ?? this.getCurrentIdentity();
const prefs = {
displayName: reqIdentity?.displayName ?? "",
timezone: reqIdentity?.preferences?.timezone ?? "",
locale: reqIdentity?.preferences?.locale ?? "en-US",
};
// Build participants for shared conversations
let participants: ParticipantInfo[] | undefined;
if (conversation.visibility === "shared" && conversation.participants?.length) {
participants = [];
for (const userId of conversation.participants) {
const user = await this._userStore.get(userId);
participants.push({
userId,
displayName: user?.displayName ?? userId,
});
}
}
const workspaceContext = workspace ? { id: workspace.id, name: workspace.name } : { id: wsId };
// Build per-request context skills with workspace identity override
const identityOverride = workspace?.identity ? makeIdentitySkill(workspace.identity) : null;
const requestContextSkills = identityOverride
? [...this.contextSkills, identityOverride]
: this.contextSkills;
// Layer 3 selection — pick skills with `loading_strategy: always` and
// `tool_affined` strategies based on the active tool set. The merged pool
// includes platform / workspace / user tier skills (user > workspace >
// platform on name collisions).
const userId = reqIdentity?.id ?? null;
const layer3Pool = this.loadConversationSkills(wsId, userId);
const activeToolNames = tools.map((t) => t.name);
const selectedLayer3 = selectLayer3Skills({
skills: layer3Pool,
activeTools: activeToolNames,
});
const layer3Entries: Layer3SkillEntry[] = selectedLayer3.map((s) => ({
name: s.skill.manifest.name,
body: s.skill.body,
scope: s.skill.manifest.scope ?? "org",
...(s.skill.sourcePath ? { sourcePath: s.skill.sourcePath } : {}),
loadedBy: s.loadedBy,
reason: s.reason,
}));
const systemPrompt = composeSystemPrompt(
requestContextSkills,
skill,
apps,
focusedApp,
appState,
prefs,
proxied.length > 0,
participants,
workspaceContext,
liveOverlays,
layer3Entries,
);
// Load history and rehydrate any `resource_link` blocks (attached
// images persisted as URI references) into AI SDK V3 `file` parts
// with bytes loaded from the workspace FileStore. This is the seam
// where the storage shape (URI references) meets the model-call
// shape (inline bytes) — see `src/files/rehydrate.ts`.
const history = await store.history(conversation);
const fileStore = createFileStore(join(this.getWorkspaceScopedDir(wsId), "files"));
const messages = await rehydrateUserResources(history, fileStore);
// Workspace model overrides are in the RequestContext — read via getModelSlot()
// Resolve model: support alias references (e.g., "alias:fast", "alias:reasoning")
let resolvedModelString = request.model ?? this.getDefaultModel();
const aliasSlot = parseAliasRef(resolvedModelString);
if (aliasSlot) {
resolvedModelString = this.getModelSlot(aliasSlot);
}
// Qualify bare model ids at the request-entry boundary. Slot-read
// values are already qualified by `getModelSlots()`, but the per-
// request `request.model` override path bypasses that reader, so
// we normalize once here to cover both. Belt-and-suspenders with
// the slot reader: the rest of the pipeline (cost aggregation,
// capability checks, max-output and thinking resolvers, provider-
// options shape, log lines) reads `engineConfig.model` directly
// and depends on it being qualified.
resolvedModelString = resolveModelString(resolvedModelString);
// Resolve maxOutputTokens FIRST — resolveThinking needs it to clamp the
// thinking budget so visible-content headroom is always preserved.
const resolvedMaxOutputTokens = resolveMaxOutputTokens({
configValue: this.config.maxOutputTokens,
model: resolvedModelString,
});
const resolvedThinking = resolveThinking({
configMode: this.config.thinking,
configBudgetTokens: this.config.thinkingBudgetTokens,
model: resolvedModelString,
maxOutputTokens: resolvedMaxOutputTokens,
});
// Build pre-emit run telemetry tied to the engine's runId. The engine fires
// these immediately after `run.start` and before any LLM call so the conv
// log records what the prompt looked like for this turn — even if the LLM
// call fails or the process is killed.
const skillsLoaded = buildSkillsLoadedPayload(selectedLayer3);
const contextAssembled = buildContextAssembledPayload({
systemPrompt,
activeTools: tools,
messages,
skillsLoaded,
});
const engineConfig: EngineConfig = {
model: resolvedModelString,
maxIterations: request.maxIterations ?? this.config.maxIterations ?? DEFAULT_MAX_ITERATIONS,
maxInputTokens: this.config.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS,
maxOutputTokens: resolvedMaxOutputTokens,
...(resolvedThinking ? { thinking: resolvedThinking } : {}),
maxToolResultSize: this.config.maxToolResultSize,
hooks: this.hooks,
runMetadata: {
skillsLoaded,
contextAssembled,
},
// Agent-loop identity rule: tool calls inside this run authenticate
// as the request's identity (= conversation owner for chat-based
// runs). Member-scoped MCP bundles use this to route to the right
// per-principal source. Workspace-scoped sources ignore it.
...(request.identity ? { principalId: request.identity.id } : {}),
};
// Determine which event store handles conversation events for this request.
// For workspace-scoped requests, use the workspace store instead of the global one.
const isWorkspaceRequest =
store instanceof EventSourcedConversationStore && store !== this.store;
let activeEventStore: EventSourcedConversationStore | null = null;
if (isWorkspaceRequest) {
// Disable global store for this request, use workspace store instead
if (this.eventStore) this.eventStore.setActiveConversation("");
activeEventStore = store as EventSourcedConversationStore;
activeEventStore.setActiveConversation(conversation.id);
} else if (this.eventStore) {
activeEventStore = this.eventStore;
this.eventStore.setActiveConversation(conversation.id);
}
// Build per-request sink chain. The engine itself returns cumulative
// usage and llmMs in its EngineResult — no need for a side-channel
// metrics collector.
const sinks: EventSink[] = requestSink
? [requestSink, this.defaultEvents]
: [this.defaultEvents];
if (isWorkspaceRequest) {
sinks.push(store as EventSourcedConversationStore);
}
const model = engineConfig.model;
const resolvedModel = this.resolveModelFn(model);
const engine = new AgentEngine(resolvedModel, activeRegistry, new MultiEventSink(sinks));
// When auth is configured, identity and workspace are mandatory.
// Reject early rather than running in a degraded state with null identity.
if (this._identityProvider) {
if (!request.identity) {
throw new Error(
"Identity required: auth provider is configured but no identity was provided to runtime.chat()",
);
}
}
// In dev mode (no auth provider), fall back to the dev identity.
const requestIdentity = request.identity ?? DEV_IDENTITY;
// Build the request context for AsyncLocalStorage.
// This makes identity/workspace available to all async operations within
// engine.run() (including parallel tool calls) without mutable singletons.
const reqCtx: RequestContext = {
identity: requestIdentity,
workspaceId: wsId,
workspaceAgents: workspace?.agents ?? null,
workspaceModelOverride: workspace?.models ?? null,
conversationId: conversation.id,
};
// Emit chat.start so the client knows the conversation ID immediately
// and conversation list UIs can refresh
if (requestSink) {
requestSink.emit({
type: "chat.start",
data: { conversationId: conversation.id },