@myagentroam/node 0.11.20 → 0.11.22

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.
@@ -14,6 +14,7 @@ const runtimeCredentialCapability = () => ({
14
14
  responsesOptionalParameters: true,
15
15
  responsesHttpContinuation: true,
16
16
  conversationModels: true,
17
+ resourceInput: true,
17
18
  httpVersions: ['1.1', '2']
18
19
  });
19
20
  const workspaceDirectTransferCapability = () => ({
package/dist/connector.js CHANGED
@@ -83,6 +83,7 @@ import { SessionCommandService } from './service/session-command-service.js';
83
83
  import { SessionPresentationService, sessionGroup } from './service/session-presentation-service.js';
84
84
  import { isPlainRecord, isRunnerImageAttachment, parseRunnerImageAttachments } from './util/node-operation-parsers.js';
85
85
  import { WebToolService } from './service/web-tool-service.js';
86
+ import { ModelResourcePublicationService } from './service/model-resource-publication-service.js';
86
87
  export { nodeConnectEndpoint, validatedCapabilities } from './util/node-operation-parsers.js';
87
88
  import { NodeControlChannel } from './service/node-control-channel.js';
88
89
  import { NodeControlMessageService } from './service/node-control-message-service.js';
@@ -241,6 +242,7 @@ export class NodeConnector {
241
242
  sessionCatalogService;
242
243
  sessionCommandService;
243
244
  runAttachmentService;
245
+ modelResourcePublicationService;
244
246
  terminalChannel;
245
247
  codexClient;
246
248
  codexRunController;
@@ -448,6 +450,11 @@ export class NodeConnector {
448
450
  capabilities: () => this.capabilities,
449
451
  probe: this.conversationHostingProbe,
450
452
  runtime: {
453
+ publishModelResource: (image, context) => this.modelResourcePublicationService.publish({
454
+ conversationId: context.conversationId,
455
+ sessionId: context.sessionId,
456
+ homeDirectory: context.homeDirectory
457
+ }, image),
451
458
  webSearch: (input, signal) => this.webToolService.webSearch(input, signal),
452
459
  webFetch: (input, signal) => this.webToolService.webFetch(input, signal, false),
453
460
  modelFactory: this.marAgentRuntimeModels,
@@ -502,6 +509,20 @@ export class NodeConnector {
502
509
  this.fakeScript = options.fakeScript;
503
510
  this.runAttachmentService = new RunAttachmentService((session) => this.runners.require(session.runner).nativeImageRoots(session), (sessionId) => this.runtime.activeSessionRun(sessionId) !== undefined ||
504
511
  this.runtime.sessionQueue(sessionId).itemIds.length > 0, () => this.attachmentTemporaryRoot());
512
+ this.modelResourcePublicationService = new ModelResourcePublicationService({
513
+ directory: () => resolve(nodeDataDirectory(this.config, this.configPath), 'model-resources'),
514
+ nodeId: () => this.config?.nodeId,
515
+ origin: () => {
516
+ if (!this.config)
517
+ return undefined;
518
+ const url = new URL(this.config.serverUrl);
519
+ if (url.protocol === 'ws:')
520
+ url.protocol = 'http:';
521
+ if (url.protocol === 'wss:')
522
+ url.protocol = 'https:';
523
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined;
524
+ }
525
+ });
505
526
  this.codexClient = new CodexRunner(options.codexClient, options.codexProfileClientFactory, options.codexRunClientFactory);
506
527
  this.codexRunController = new CodexManagedRunController(this.codexClient, {
507
528
  available: () => this.capabilities.codex.available,
@@ -593,6 +614,16 @@ export class NodeConnector {
593
614
  }
594
615
  }, {
595
616
  modelFactory: this.marAgentRuntimeModels,
617
+ publishModelResource: (image, context) => {
618
+ const session = this.runtime.getAgentSession(context.ownerSessionId);
619
+ if (session === undefined)
620
+ throw new Error('RUN_SESSION_NOT_FOUND');
621
+ return this.modelResourcePublicationService.publish({
622
+ workspaceId: session.workspaceId,
623
+ sessionId: context.sessionId,
624
+ homeDirectory: context.homeDirectory
625
+ }, image);
626
+ },
596
627
  retainEnvironment: (environment) => this.gitExecution().retain(environment),
597
628
  computerUse: this.computerUseProvider
598
629
  });
@@ -723,6 +754,7 @@ export class NodeConnector {
723
754
  readImage: async (runId, selector) => typeof selector === 'number'
724
755
  ? this.runAttachmentService.read(runId, selector)
725
756
  : this.runAttachmentService.readNative(runId, selector),
757
+ readModelResource: (input) => this.modelResourcePublicationService.read(input),
726
758
  respondUserInput: (runId, requestId, answers) => this.runnerInteractionService.respondUserInput(runId, requestId, answers),
727
759
  pauseQueue: (sessionId) => this.workspaceQueueWorkbenchService.pauseSessionQueue(sessionId),
728
760
  runInterrupted: (sessionId) => {
@@ -1681,6 +1713,7 @@ export class NodeConnector {
1681
1713
  this.runEventService.clear();
1682
1714
  this.sessionContextService.dispose();
1683
1715
  this.runAttachmentService.dispose();
1716
+ this.modelResourcePublicationService.dispose();
1684
1717
  const marAgentDrain = this.marAgentRunController.dispose();
1685
1718
  const conversationHostingDrain = this.conversationHosting.dispose();
1686
1719
  const uploadDrain = this.workspaceUploadService.shutdown();
@@ -70,18 +70,21 @@ export async function generateMarAgentCompact(context, selected, signal) {
70
70
  const budget = Math.floor(window * 0.9) - overhead;
71
71
  let modelRequests = 0;
72
72
  let inputTokensEstimateAfter = overhead;
73
+ let failure = {};
73
74
  const report = async (state, reason, errorCode) => {
74
75
  try {
75
76
  await context.reportDiagnostic?.({
76
77
  modelId: selected.model.id,
77
78
  providerModelId: selected.model.modelId,
78
79
  reasoningEffort: selected.reasoningEffort,
80
+ trigger: context.reason,
79
81
  state,
80
82
  inputTokensEstimateBefore: countModelTokens(full) + overhead,
81
83
  inputTokensEstimateAfter,
82
84
  modelRequests,
83
85
  elapsedMs: Math.max(0, Date.now() - startedAt),
84
86
  ...(reason === undefined ? {} : { reason }),
87
+ ...failure,
85
88
  ...(errorCode === undefined ? {} : { errorCode })
86
89
  });
87
90
  }
@@ -114,9 +117,20 @@ export async function generateMarAgentCompact(context, selected, signal) {
114
117
  outputLimit: Math.min(selected.model.maxOutputTokens, context.summaryTokenBudget),
115
118
  onAttemptDiagnostic: async (diagnostic) => {
116
119
  if (diagnostic.state === 'STARTED') {
120
+ failure = {};
117
121
  modelRequests++;
118
122
  await report('STARTED');
119
123
  }
124
+ else if (diagnostic.state === 'FAILED') {
125
+ failure = {
126
+ ...(diagnostic.errorCode === undefined ? {} : { errorCode: diagnostic.errorCode }),
127
+ ...(diagnostic.causeCode === undefined ? {} : { causeCode: diagnostic.causeCode }),
128
+ ...(diagnostic.reason === undefined ? {} : { errorReason: diagnostic.reason }),
129
+ ...(diagnostic.retryable === undefined ? {} : { retryable: diagnostic.retryable }),
130
+ transport: diagnostic.transport
131
+ };
132
+ await report('FAILED', 'MODEL_FAILED');
133
+ }
120
134
  },
121
135
  signal
122
136
  });
@@ -127,7 +141,18 @@ export async function generateMarAgentCompact(context, selected, signal) {
127
141
  }
128
142
  catch (error) {
129
143
  signal.throwIfAborted();
130
- await report('FAILED', 'MODEL_FAILED', error instanceof MarAgentError ? error.code : 'MAR_AGENT_COMPACT_MODEL_FAILED');
144
+ if (error instanceof MarAgentError) {
145
+ const status = error.details?.status;
146
+ failure = {
147
+ ...failure,
148
+ retryable: error.retryable,
149
+ ...(typeof status === 'number' ? { httpStatus: status } : {}),
150
+ ...(typeof error.details?.reason === 'string' ? { errorReason: error.details.reason } : {})
151
+ };
152
+ }
153
+ await report('FAILED', 'MODEL_FAILED', error instanceof MarAgentError
154
+ ? error.code
155
+ : (failure.errorCode ?? 'MAR_AGENT_COMPACT_MODEL_FAILED'));
131
156
  return undefined;
132
157
  }
133
158
  }
@@ -17,6 +17,7 @@ export class MarAgentHostedConversationRuntime {
17
17
  #createAgent;
18
18
  #generateTitle;
19
19
  #createHost;
20
+ #publishModelResource;
20
21
  #openCatalog;
21
22
  #now;
22
23
  #onEvent;
@@ -26,6 +27,7 @@ export class MarAgentHostedConversationRuntime {
26
27
  #modelFactory;
27
28
  #registerNativeImages;
28
29
  constructor(options = {}) {
30
+ this.#publishModelResource = options.publishModelResource;
29
31
  this.#createAgent = options.createAgent ?? createMarAgent;
30
32
  this.#generateTitle = options.generateTitle ?? generateSessionTitle;
31
33
  this.#createHost = options.createHost ?? ((input) => new LocalAgentHost(input));
@@ -341,6 +343,14 @@ export class MarAgentHostedConversationRuntime {
341
343
  ...(this.#webFetch === undefined ? {} : { webFetch: this.#webFetch })
342
344
  }));
343
345
  host = this.#createHost({
346
+ ...(this.#publishModelResource === undefined
347
+ ? {}
348
+ : {
349
+ publishModelResource: (image, context) => this.#publishModelResource(image, {
350
+ ...context,
351
+ conversationId: input.conversationId
352
+ })
353
+ }),
344
354
  ...('onModelUsage' in input && input.onModelUsage !== undefined
345
355
  ? { modelUsage: input.onModelUsage }
346
356
  : {}),
@@ -46,7 +46,7 @@ export function createImageGenerationExtensions(input) {
46
46
  .host()
47
47
  .executeTool({ name: 'view_image', arguments: { path } }, context);
48
48
  const image = output.images?.length === 1 ? output.images[0] : undefined;
49
- if (image === undefined)
49
+ if (image === undefined || image.dataBase64 === undefined)
50
50
  throw new MarAgentError('MAR_AGENT_TOOL_OUTPUT_INVALID', 'Referenced image result is invalid.');
51
51
  const data = Buffer.from(image.dataBase64, 'base64');
52
52
  if (data.byteLength === 0)
@@ -52,6 +52,7 @@ export class MarAgentManagedRunController {
52
52
  }),
53
53
  customTitle: (sessionId) => this.host.customTitle?.(sessionId) ?? undefined,
54
54
  modelUsage: (runId, usage) => this.host.emit(runId, 'run.usage', normalizeMarAgentUsage(usage)),
55
+ publishModelResource: options.publishModelResource,
55
56
  question: (runId, request, signal) => this.requestQuestion(runId, request, signal),
56
57
  ...(this.host.createApproval && this.host.publishApproval
57
58
  ? {
@@ -1,4 +1,4 @@
1
- import { SecretValue } from '@myagentroam/agent';
1
+ import { MarAgentError, SecretValue } from '@myagentroam/agent';
2
2
  import { marAgentRuntimeModelConfigurationSchema, resolveMarAgentDefaultReasoningEffort } from '@myagentroam/protocol';
3
3
  export function sdkModelConfigurations(models) {
4
4
  return models.map(({ imageGeneration: _imageGeneration, ...model }) => model);
@@ -72,6 +72,7 @@ export class MarAgentRuntimeModelFactory {
72
72
  ? { fastMode: true }
73
73
  : {}),
74
74
  hostedWebSearch: configuration.hostedWebSearch,
75
+ useResourceUrl: configuration.useResourceUrl,
75
76
  ...(imageGeneration === undefined ? {} : { imageGeneration }),
76
77
  ...(configuration.responsesTransport === undefined
77
78
  ? {}
@@ -94,11 +95,11 @@ export class MarAgentRuntimeModelFactory {
94
95
  apiKey: new SecretValue(credential.value)
95
96
  };
96
97
  if (credential?.type !== 'RUNTIME_TICKET')
97
- throw new Error('MAR_AGENT_RUNTIME_MODEL_CREDENTIAL_INVALID');
98
+ throw new MarAgentError('MAR_AGENT_RUNTIME_MODEL_CREDENTIAL_INVALID', 'The runtime model credential is invalid.');
98
99
  if (context.runtimeScopeId === undefined ||
99
100
  context.runtimeScopeId !== credential.ticket.runtimeScopeId ||
100
101
  !credential.ticket.allowedModelIds.includes(configuration.id))
101
- throw new Error('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID');
102
+ throw new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID', 'The runtime credential does not authorize this execution or model.');
102
103
  const provider = runtimeProvider(credential, configuration.id, context, tickets, providers, this.control, this.now);
103
104
  return {
104
105
  ...model,
@@ -140,7 +141,7 @@ function runtimeProvider(credential, consumerId, context, tickets, providers, co
140
141
  if (context.runtimeScopeId === undefined ||
141
142
  context.runtimeScopeId !== credential.ticket.runtimeScopeId ||
142
143
  !credential.ticket.allowedModelIds.includes(consumerId))
143
- throw new Error('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID');
144
+ throw new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID', 'The runtime credential does not authorize this execution or model.');
144
145
  const key = `${credential.credentialId}\0${credential.ticket.runtimeScopeId}\0${credential.ticket.purpose ?? 'MODEL'}`;
145
146
  let state = tickets.get(key);
146
147
  if (state === undefined) {
@@ -152,7 +153,7 @@ function runtimeProvider(credential, consumerId, context, tickets, providers, co
152
153
  tickets.set(key, state);
153
154
  }
154
155
  else if (!sameRuntimeCredentialTicket(state.ticket, credential.ticket))
155
- throw new Error('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID');
156
+ throw new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID', 'The runtime credential does not authorize this execution or model.');
156
157
  const provider = new ServerRuntimeCredentialProvider(control, state, consumerId, context.runtimeScopeId, context.executionId, now);
157
158
  providers.push(provider);
158
159
  return provider;
@@ -178,7 +179,7 @@ class ServerRuntimeCredentialProvider {
178
179
  }
179
180
  async acquire(reason) {
180
181
  if (this.#closed || this.state.released)
181
- throw new Error('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED');
182
+ throw new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED', 'The runtime credential provider is closed.');
182
183
  if (isReusableCredential(this.state.credential, this.now())) {
183
184
  if (isWithinRefreshWindow(this.state.ticket.expiresAt, this.now()))
184
185
  this.refreshAutomatically();
@@ -263,9 +264,9 @@ class ServerRuntimeCredentialProvider {
263
264
  reason
264
265
  }, signal);
265
266
  if (this.state.released)
266
- throw new Error('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED');
267
+ throw new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED', 'The runtime credential provider is closed.');
267
268
  if ('error' in response)
268
- throw new Error(response.error.code);
269
+ throw new MarAgentError(response.error.code, response.error.message);
269
270
  this.state.ticket = response.nextTicket;
270
271
  const credential = {
271
272
  revision: response.credential.revision,
@@ -332,11 +333,11 @@ function clearRefreshTimer(state) {
332
333
  }
333
334
  function waitForCredential(pending, signal) {
334
335
  if (signal.aborted)
335
- return Promise.reject(new Error('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED'));
336
+ return Promise.reject(new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED', 'The runtime credential provider is closed.'));
336
337
  return new Promise((resolve, reject) => {
337
338
  const abort = () => {
338
339
  cleanup();
339
- reject(new Error('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED'));
340
+ reject(new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED', 'The runtime credential provider is closed.'));
340
341
  };
341
342
  const cleanup = () => signal.removeEventListener('abort', abort);
342
343
  signal.addEventListener('abort', abort, { once: true });
@@ -30,6 +30,12 @@ export async function createSessionRuntime(input, sessionKey, fingerprint, hostC
30
30
  releaseEnvironment = dependencies.retainEnvironment?.(input.secretEnvironment ?? {});
31
31
  const mcp = mcpLease.pool;
32
32
  const localHost = new LocalAgentHost({
33
+ publishModelResource: dependencies.publishModelResource === undefined
34
+ ? undefined
35
+ : (image, context) => dependencies.publishModelResource(image, {
36
+ ...context,
37
+ ownerSessionId: input.sessionId
38
+ }),
33
39
  modelUsage: (usage) => {
34
40
  const runId = currentRunId();
35
41
  if (runId !== undefined)
@@ -1,4 +1,4 @@
1
- import { marAgentNodeModelSummarySchema, resolveMarAgentDefaultReasoningEffort } from '@myagentroam/protocol';
1
+ import { marAgentNodeModelSummarySchema, parseComposerTags, resolveMarAgentDefaultReasoningEffort, serializeComposerTag } from '@myagentroam/protocol';
2
2
  import { nodeLog } from '../operational.js';
3
3
  import { runnerErrorProjection } from '../util/runner-error.js';
4
4
  import { MarAgentError, openMarAgentSessionCatalog } from '@myagentroam/agent';
@@ -11,6 +11,22 @@ import { captureMarAgentExecutionInput } from './mar-agent/execution-input.js';
11
11
  export class MarAgentRunner extends AbstractRunner {
12
12
  homeDirectory;
13
13
  name = 'mar-agent';
14
+ prepareMessageInput(input, collaborationMode) {
15
+ const prepared = super.prepareMessageInput(input, collaborationMode);
16
+ return parseComposerTags(prepared)
17
+ .map((part) => {
18
+ if (part.kind === 'text')
19
+ return part.text;
20
+ if (part.kind === 'mar_attachments' &&
21
+ part.payload.files.some((file) => file.mime.startsWith('image/')))
22
+ return serializeComposerTag('mar_attachments', {
23
+ ...part.payload,
24
+ instruction: 'Images are included. Use view_image only to revisit them.'
25
+ });
26
+ return part.raw;
27
+ })
28
+ .join('');
29
+ }
14
30
  prepareExecutionContext(input) {
15
31
  return { runner: this.name, runnerInput: captureMarAgentExecutionInput(input) };
16
32
  }
@@ -0,0 +1,148 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { MarAgentError, openMarAgentSessionCatalog } from '@myagentroam/agent';
5
+ import { modelResourceReadInputSchema } from '@myagentroam/protocol';
6
+ const TTL_MS = 24 * 60 * 60_000;
7
+ /** Only capability metadata lives here. The SDK owns the sole immutable image file. */
8
+ export class ModelResourcePublicationService {
9
+ options;
10
+ #pending = new Map();
11
+ #key;
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ async publish(source, resource) {
16
+ const origin = this.options.origin();
17
+ const nodeId = this.options.nodeId();
18
+ if (origin === undefined || nodeId === undefined)
19
+ return undefined;
20
+ const id = this.identity(source, resource.resourceId);
21
+ const current = this.#pending.get(id);
22
+ if (current !== undefined)
23
+ return current;
24
+ const creation = this.create(id, source, resource.resourceId, origin, nodeId);
25
+ this.#pending.set(id, creation);
26
+ try {
27
+ return await creation;
28
+ }
29
+ finally {
30
+ if (this.#pending.get(id) === creation)
31
+ this.#pending.delete(id);
32
+ }
33
+ }
34
+ async create(id, source, resourceId, origin, nodeId) {
35
+ const key = await this.signingKey();
36
+ try {
37
+ const record = JSON.parse(await readFile(this.path(id), 'utf8'));
38
+ if (record.version !== 2 || this.identity(record.source, record.resourceId) !== id)
39
+ throw new Error('MODEL_RESOURCE_INVALID');
40
+ return { url: record.url, urlExpiresAt: record.expiresAt };
41
+ }
42
+ catch (error) {
43
+ if (error.code !== 'ENOENT')
44
+ throw error;
45
+ }
46
+ const expiresAt = this.now() + TTL_MS;
47
+ const signature = this.signature(key, nodeId, source, resourceId, expiresAt);
48
+ const scope = 'workspaceId' in source
49
+ ? `workspaces/${encodeURIComponent(source.workspaceId)}`
50
+ : `conversations/${encodeURIComponent(source.conversationId)}`;
51
+ const url = `${origin}/api/nodes/${encodeURIComponent(nodeId)}/${scope}/sessions/${encodeURIComponent(source.sessionId)}/resources/${resourceId}?expiresAt=${expiresAt}&signature=${signature}`;
52
+ const record = { version: 2, source, resourceId, expiresAt, url };
53
+ const temporary = join(this.options.directory(), `.${id}.${randomUUID()}.tmp`);
54
+ try {
55
+ await writeFile(temporary, JSON.stringify(record), { mode: 0o600, flag: 'wx', flush: true });
56
+ await rename(temporary, this.path(id));
57
+ }
58
+ finally {
59
+ await rm(temporary, { force: true });
60
+ }
61
+ return { url, urlExpiresAt: expiresAt };
62
+ }
63
+ async read(input) {
64
+ if (!modelResourceReadInputSchema.safeParse(input).success)
65
+ return undefined;
66
+ if (input.expiresAt <= this.now())
67
+ return undefined;
68
+ const nodeId = this.options.nodeId();
69
+ if (nodeId === undefined)
70
+ return undefined;
71
+ const scope = 'workspaceId' in input
72
+ ? { workspaceId: input.workspaceId }
73
+ : { conversationId: input.conversationId };
74
+ const source = { ...scope, sessionId: input.sessionId };
75
+ const expected = this.signature(await this.signingKey(), nodeId, source, input.resourceId, input.expiresAt);
76
+ if (!timingSafeEqual(Buffer.from(expected), Buffer.from(input.signature)))
77
+ return undefined;
78
+ let record;
79
+ try {
80
+ record = JSON.parse(await readFile(this.path(this.identity(source, input.resourceId)), 'utf8'));
81
+ }
82
+ catch (error) {
83
+ if (error.code === 'ENOENT')
84
+ return undefined;
85
+ throw error;
86
+ }
87
+ if (record.version !== 2 ||
88
+ record.expiresAt !== input.expiresAt ||
89
+ this.identity(record.source, record.resourceId) !== this.identity(source, input.resourceId))
90
+ return undefined;
91
+ const catalog = await openMarAgentSessionCatalog({
92
+ homeDirectory: record.source.homeDirectory
93
+ });
94
+ try {
95
+ return await catalog.readResource(record.source.sessionId, record.resourceId);
96
+ }
97
+ catch (error) {
98
+ if (error instanceof MarAgentError && error.code === 'MAR_AGENT_SESSION_NOT_FOUND')
99
+ return undefined;
100
+ throw error;
101
+ }
102
+ finally {
103
+ await catalog.close();
104
+ }
105
+ }
106
+ dispose() {
107
+ this.#pending.clear();
108
+ }
109
+ now() {
110
+ return this.options.now?.() ?? Date.now();
111
+ }
112
+ path(id) {
113
+ return join(this.options.directory(), `${id}.json`);
114
+ }
115
+ identity(source, resourceId) {
116
+ return createHash('sha256')
117
+ .update(JSON.stringify([
118
+ 'workspaceId' in source ? 'workspace' : 'conversation',
119
+ 'workspaceId' in source ? source.workspaceId : source.conversationId,
120
+ source.sessionId,
121
+ resourceId
122
+ ]))
123
+ .digest('hex');
124
+ }
125
+ signature(key, nodeId, source, resourceId, expiresAt) {
126
+ return createHmac('sha256', key)
127
+ .update(JSON.stringify([nodeId, this.identity(source, resourceId), expiresAt]))
128
+ .digest('base64url');
129
+ }
130
+ signingKey() {
131
+ return (this.#key ??= (async () => {
132
+ const directory = this.options.directory();
133
+ await mkdir(directory, { recursive: true, mode: 0o700 });
134
+ const path = join(directory, 'signing-key');
135
+ try {
136
+ await writeFile(path, randomBytes(32), { mode: 0o600, flag: 'wx', flush: true });
137
+ }
138
+ catch (error) {
139
+ if (error.code !== 'EEXIST')
140
+ throw error;
141
+ }
142
+ const key = await readFile(path);
143
+ if (key.length !== 32)
144
+ throw new Error('MODEL_RESOURCE_SIGNING_KEY_INVALID');
145
+ return key;
146
+ })());
147
+ }
148
+ }
@@ -1,4 +1,4 @@
1
- import { runImageReadInputSchema } from '@myagentroam/protocol';
1
+ import { modelResourceReadInputSchema, runImageReadInputSchema } from '@myagentroam/protocol';
2
2
  import { nodeOperationData } from '../util/node-operation-data.js';
3
3
  import { isTerminalRunStatus } from '../util/node-operation-parsers.js';
4
4
  import { isPlainRecord } from '../util/node-operation-parsers.js';
@@ -13,6 +13,7 @@ export class RunWorkbenchService {
13
13
  return {
14
14
  'run.get': (data) => this.get(record(data)),
15
15
  'run.image.read': (data) => this.image(record(data)),
16
+ 'model.resource.read': (data) => this.modelResource(record(data)),
16
17
  'run.user-input.respond': (data) => this.respond(record(data)),
17
18
  'session.interrupt': (data) => this.interruptSession(record(data)),
18
19
  'run.cancel': (data) => this.cancelOrInterrupt('run.cancel', record(data)),
@@ -20,6 +21,15 @@ export class RunWorkbenchService {
20
21
  'run.approval.decide': (data) => this.decideApproval(record(data))
21
22
  };
22
23
  }
24
+ async modelResource(input) {
25
+ const parsed = modelResourceReadInputSchema.safeParse(nodeOperationData(input));
26
+ if (!parsed.success)
27
+ throw new Error('RUN_IMAGE_INVALID');
28
+ const image = await this.options.readModelResource?.(parsed.data);
29
+ if (image === undefined)
30
+ throw new Error('RUN_IMAGE_NOT_FOUND');
31
+ return { image };
32
+ }
23
33
  get(input) {
24
34
  return { run: this.requireRun(input.runId) };
25
35
  }
@@ -1,3 +1,4 @@
1
+ import { MarAgentError } from '@myagentroam/agent';
1
2
  import { createEnvelope, runtimeCredentialResolveRequestSchema, runtimeCredentialResolveResponseSchema } from '@myagentroam/protocol';
2
3
  const DEFAULT_TIMEOUT_MS = 15_000;
3
4
  /** Correlates the closed runtime-credential exchange on the authenticated control connection. */
@@ -32,7 +33,7 @@ export class RuntimeCredentialControlService {
32
33
  return true;
33
34
  const parsed = runtimeCredentialResolveResponseSchema.safeParse(envelope.payload);
34
35
  if (!parsed.success) {
35
- pending.reject(new Error('MAR_AGENT_RUNTIME_CREDENTIAL_RESPONSE_INVALID'));
36
+ pending.reject(new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_RESPONSE_INVALID', 'The runtime credential response is invalid.'));
36
37
  return true;
37
38
  }
38
39
  pending.resolve(parsed.data);
@@ -40,9 +41,9 @@ export class RuntimeCredentialControlService {
40
41
  }
41
42
  resolve(request, signal) {
42
43
  if (this.#closed)
43
- return Promise.reject(new Error('MAR_AGENT_RUNTIME_CREDENTIAL_CONTROL_CLOSED'));
44
+ return Promise.reject(new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_CONTROL_CLOSED', 'The runtime credential control service is closed.'));
44
45
  if (!this.#connected)
45
- return Promise.reject(new Error('MAR_AGENT_RUNTIME_CREDENTIAL_CONNECTION_INVALID'));
46
+ return Promise.reject(new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_CONNECTION_INVALID', 'The runtime credential control connection is unavailable.'));
46
47
  const parsed = runtimeCredentialResolveRequestSchema.parse(request);
47
48
  const envelope = createEnvelope('credential.resolve', parsed);
48
49
  const generation = this.#generation;
@@ -56,8 +57,8 @@ export class RuntimeCredentialControlService {
56
57
  pending.cleanup();
57
58
  action(value);
58
59
  };
59
- const abort = () => settle((error) => reject(error), new Error('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED'));
60
- const timer = setTimeout(() => settle((error) => reject(error), new Error('MAR_AGENT_RUNTIME_CREDENTIAL_REQUEST_TIMEOUT')), this.#timeoutMs);
60
+ const abort = () => settle((error) => reject(error), new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_PROVIDER_CLOSED', 'The runtime credential provider is closed.'));
61
+ const timer = setTimeout(() => settle((error) => reject(error), new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_REQUEST_TIMEOUT', 'The runtime credential request timed out.')), this.#timeoutMs);
61
62
  timer.unref();
62
63
  this.#pending.set(envelope.id, {
63
64
  generation,
@@ -74,9 +75,7 @@ export class RuntimeCredentialControlService {
74
75
  this.options.send(envelope);
75
76
  }
76
77
  catch (error) {
77
- settle((cause) => reject(cause), error instanceof Error
78
- ? error
79
- : new Error('MAR_AGENT_RUNTIME_CREDENTIAL_REQUEST_SEND_FAILED'));
78
+ settle((cause) => reject(cause), new MarAgentError('MAR_AGENT_RUNTIME_CREDENTIAL_REQUEST_SEND_FAILED', 'The runtime credential request could not be sent.', { cause: error }));
80
79
  }
81
80
  });
82
81
  }
@@ -90,6 +89,6 @@ export class RuntimeCredentialControlService {
90
89
  }
91
90
  #rejectPending(code) {
92
91
  for (const pending of [...this.#pending.values()])
93
- pending.reject(new Error(code));
92
+ pending.reject(new MarAgentError(code, 'The runtime credential control connection is no longer valid.'));
94
93
  }
95
94
  }
@@ -178,7 +178,8 @@ export class SessionLifecycleService {
178
178
  });
179
179
  if (input.bootstrapOnly === true)
180
180
  this.options.identity.markBootstrap(session.id);
181
- database.saveRunnerDefaults(configuration);
181
+ if (input.__marctlOrigin === undefined)
182
+ database.saveRunnerDefaults(configuration);
182
183
  if (input.bootstrapOnly !== true)
183
184
  this.options.emitSession(session);
184
185
  return { session: this.options.present(session) };
@@ -22,6 +22,12 @@ export class WorkspaceWatchController {
22
22
  }
23
23
  const topics = this.options.watch.parseTopics(input);
24
24
  const existing = this.options.watch.existing(workspace.id, key);
25
+ const groupOnly = existing !== undefined &&
26
+ existing.topics.sessions === topics.sessions &&
27
+ existing.topics.sessionGroup !== topics.sessionGroup &&
28
+ existing.topics.changes === topics.changes &&
29
+ JSON.stringify(existing.topics.files) === JSON.stringify(topics.files) &&
30
+ (existing.prepareFiles === true) === (input.prepareFiles === true);
25
31
  if (existing !== undefined &&
26
32
  existing.watchId === input.watchId &&
27
33
  JSON.stringify(existing.topics) === JSON.stringify(topics) &&
@@ -30,7 +36,7 @@ export class WorkspaceWatchController {
30
36
  return this.accepted(existing);
31
37
  }
32
38
  const watch = this.options.watch.create(workspace.id, input.watchId, this.options.watch.sessionHash(input), input.workbenchConnectionId, this.options.nodeGeneration(), topics, input.prepareFiles === true);
33
- void this.options.snapshots.initial(workspace, watch);
39
+ void this.options.snapshots.initial(workspace, watch, groupOnly ? existing : undefined);
34
40
  if (watch.prepareFiles)
35
41
  this.options.snapshots.prewarmFiles(workspace, watch);
36
42
  return this.accepted(watch);
@@ -59,8 +59,9 @@ export class WorkspaceWatchService {
59
59
  create(workspaceId, watchId, sessionHash, workbenchConnectionId, nodeGeneration, topics, prepareFiles = false) {
60
60
  const key = `${sessionHash}:${workbenchConnectionId}`;
61
61
  for (const [activeWorkspaceId, watches] of this.state.watches)
62
- if (watches.has(key))
62
+ if (activeWorkspaceId !== workspaceId && watches.has(key))
63
63
  this.remove(activeWorkspaceId, key);
64
+ const previous = this.state.watches.get(workspaceId)?.get(key);
64
65
  const watch = {
65
66
  workspaceId,
66
67
  watchId,
@@ -76,6 +77,12 @@ export class WorkspaceWatchService {
76
77
  const watches = this.state.watches.get(workspaceId) ?? new Map();
77
78
  watches.set(key, watch);
78
79
  this.state.watches.set(workspaceId, watches);
80
+ if (previous !== undefined) {
81
+ clearTimeout(previous.timer);
82
+ this.onWatchRemoved(previous);
83
+ if (previous.topics.sessions && !this.topicActive(workspaceId, 'sessions'))
84
+ this.onSessionWatchUnused(workspaceId);
85
+ }
79
86
  if (topics.sessions)
80
87
  this.onSessionWatchRequired(workspaceId);
81
88
  return watch;
@@ -7,17 +7,28 @@ export class WorkspaceWatchSnapshotService {
7
7
  dirty = new Set();
8
8
  reads = new Map();
9
9
  signatures = new Map();
10
+ // Handoff only between consecutive group selections on the same page/Workspace.
11
+ // Weak keys release the two resource payloads with their lease, not a second TTL cache.
12
+ resourceSnapshots = new WeakMap();
10
13
  filesRefreshTimer;
11
14
  constructor(options) {
12
15
  this.options = options;
13
16
  }
14
- async initial(workspace, watch) {
17
+ async initial(workspace, watch, previous) {
15
18
  if (watch.topics.files !== undefined)
16
19
  this.ensureFilesRefreshTimer();
17
20
  await Promise.all(this.subscribedTopics(watch).map(async (topic) => {
18
21
  const revision = this.readRevision(workspace.id, topic);
19
22
  try {
20
- const payload = await this.read(workspace, topic, watch);
23
+ const retained = topic === 'sessions' || previous === undefined
24
+ ? undefined
25
+ : this.resourceSnapshots.get(previous)?.[topic];
26
+ const payload = retained !== undefined &&
27
+ retained.revision === revision &&
28
+ !this.readActive(workspace.id, topic, watch) &&
29
+ !this.refreshes.has(`${workspace.id}:${topic}`)
30
+ ? retained.payload
31
+ : await this.read(workspace, topic, watch);
21
32
  if (!this.matches(workspace.id, watch))
22
33
  return;
23
34
  if (revision !== this.readRevision(workspace.id, topic)) {
@@ -218,7 +229,9 @@ export class WorkspaceWatchSnapshotService {
218
229
  ? `${workspaceId}:files:${JSON.stringify(watch.topics.files)}`
219
230
  : `${workspaceId}:${topic}:${watch.topics.sessionGroup ?? 'ALL'}`;
220
231
  }
221
- readActive(workspaceId, topic) {
232
+ readActive(workspaceId, topic, watch) {
233
+ if (topic === 'files' && watch !== undefined)
234
+ return this.reads.has(this.viewKey(workspaceId, topic, watch));
222
235
  const prefix = `${workspaceId}:${topic}`;
223
236
  return [...this.reads.keys()].some((key) => key === prefix || key.startsWith(`${prefix}:`));
224
237
  }
@@ -237,6 +250,11 @@ export class WorkspaceWatchSnapshotService {
237
250
  deliver(workspace, watch, topic, payload) {
238
251
  if (!this.matches(workspace.id, watch))
239
252
  return;
253
+ if (topic !== 'sessions') {
254
+ const snapshots = this.resourceSnapshots.get(watch) ?? {};
255
+ snapshots[topic] = { payload, revision: this.readRevision(workspace.id, topic) };
256
+ this.resourceSnapshots.set(watch, snapshots);
257
+ }
240
258
  this.options.emitWatchEvent({
241
259
  type: 'watch.workspace.snapshot',
242
260
  workbenchConnectionId: watch.workbenchConnectionId,
@@ -254,6 +272,11 @@ export class WorkspaceWatchSnapshotService {
254
272
  fail(workspaceId, watch, topic) {
255
273
  if (!this.matches(workspaceId, watch))
256
274
  return;
275
+ if (topic !== 'sessions') {
276
+ const snapshots = this.resourceSnapshots.get(watch);
277
+ if (snapshots !== undefined)
278
+ delete snapshots[topic];
279
+ }
257
280
  this.options.emitWatchEvent({
258
281
  type: 'watch.failed',
259
282
  workbenchConnectionId: watch.workbenchConnectionId,
@@ -131,11 +131,15 @@ export function validUserAuthorization(value, envelope, operation, nodeId, now =
131
131
  if (typeof value !== 'object' || value === null || nodeId === undefined)
132
132
  return false;
133
133
  const authorization = value;
134
- return (typeof authorization.userId === 'number' &&
135
- Number.isSafeInteger(authorization.userId) &&
136
- authorization.userId > 0 &&
137
- typeof authorization.sessionHash === 'string' &&
138
- /^[A-Za-z0-9_-]{43}$/.test(authorization.sessionHash) &&
134
+ const modelResourceCapability = operation === 'model.resource.read' &&
135
+ authorization.userId === 0 &&
136
+ authorization.sessionHash === 'model-resource';
137
+ return ((modelResourceCapability ||
138
+ (typeof authorization.userId === 'number' &&
139
+ Number.isSafeInteger(authorization.userId) &&
140
+ authorization.userId > 0 &&
141
+ typeof authorization.sessionHash === 'string' &&
142
+ /^[A-Za-z0-9_-]{43}$/.test(authorization.sessionHash))) &&
139
143
  authorization.nodeId === nodeId &&
140
144
  authorization.operation === operation &&
141
145
  authorization.requestId === envelope.id &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.20",
3
+ "version": "0.11.22",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "playwright": "1.62.1",
38
38
  "ws": "^8.21.3",
39
39
  "zod": "4.4.3",
40
- "@myagentroam/agent": "0.11.20",
41
- "@myagentroam/protocol": "0.11.20",
42
- "@myagentroam/code-navigation": "0.11.0",
43
- "@myagentroam/marctl": "0.11.0"
40
+ "@myagentroam/agent": "0.11.22",
41
+ "@myagentroam/marctl": "0.11.0",
42
+ "@myagentroam/protocol": "0.11.22",
43
+ "@myagentroam/code-navigation": "0.11.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"