melchizedek-agents 1.0.2 → 1.1.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 (122) hide show
  1. package/.env.example +19 -2
  2. package/CHANGELOG.md +333 -0
  3. package/DOCUMENTATION.md +99 -39
  4. package/README.md +1 -0
  5. package/config/agents/README.md +2 -2
  6. package/config/agents/examples/ares.yaml +6 -10
  7. package/config/agents/examples/assistant.yaml +6 -6
  8. package/config/agents/examples/augustin.yaml +9 -15
  9. package/config/agents/examples/cartographers.yaml +6 -6
  10. package/config/agents/examples/council.yaml +6 -6
  11. package/config/agents/examples/critic.yaml +16 -16
  12. package/config/agents/examples/harness.yaml +4 -4
  13. package/config/agents/examples/image_production.yaml +33 -32
  14. package/config/agents/examples/librarian.yaml +4 -4
  15. package/config/agents/examples/patient_advocate.yaml +6 -10
  16. package/config/agents/examples/pipeline.yaml +20 -19
  17. package/config/agents/examples/research.yaml +36 -43
  18. package/config/agents/examples/scribe.yaml +31 -32
  19. package/config/agents/examples/scriptorium.yaml +6 -6
  20. package/config/agents/examples/structured_critic.yaml +82 -0
  21. package/config/agents/examples/style_council.yaml +9 -11
  22. package/config/agents/examples/syndicate.yaml +3 -5
  23. package/config/agents/examples/tutor.yaml +2 -2
  24. package/config/agents/examples/weather.yaml +2 -2
  25. package/config/agents/syndicate.schema.json +1979 -0
  26. package/config/agents/syndicateSchema.yaml +109 -72
  27. package/config/agents/templates/README.md +1 -1
  28. package/config/agents/templates/account_memory.yaml +4 -4
  29. package/config/agents/templates/case_desk.yaml +6 -6
  30. package/config/agents/templates/conversational.yaml +2 -2
  31. package/config/agents/templates/draft_review.yaml +6 -6
  32. package/config/agents/templates/intake_extractor.yaml +61 -60
  33. package/config/agents/templates/research_brief.yaml +6 -6
  34. package/config/agents/templates/research_desk.yaml +6 -6
  35. package/config/agents/templates/review_panel.yaml +8 -8
  36. package/config/agents/templates/support_triage.yaml +30 -29
  37. package/config/agents/templates/systems_operator.yaml +21 -5
  38. package/db/migrations/0014_durable_runs.sql +31 -0
  39. package/dist/lib/a2a/app.d.ts +15 -0
  40. package/dist/lib/a2a/app.js +53 -5
  41. package/dist/lib/a2a/executor.d.ts +7 -1
  42. package/dist/lib/a2a/executor.js +34 -4
  43. package/dist/lib/a2a/oauthSetup.d.ts +90 -0
  44. package/dist/lib/a2a/oauthSetup.js +205 -0
  45. package/dist/lib/agentDialect.d.ts +66 -0
  46. package/dist/lib/agentDialect.js +134 -0
  47. package/dist/lib/compile.d.ts +10 -5
  48. package/dist/lib/compile.js +69 -37
  49. package/dist/lib/compileNative.js +2 -0
  50. package/dist/lib/dispatch.d.ts +10 -5
  51. package/dist/lib/dispatch.js +10 -5
  52. package/dist/lib/doctor.d.ts +34 -1
  53. package/dist/lib/doctor.js +47 -3
  54. package/dist/lib/loadSyndicate.d.ts +45 -2
  55. package/dist/lib/loadSyndicate.js +22 -2
  56. package/dist/lib/memory/namespace.d.ts +6 -5
  57. package/dist/lib/memory/namespace.js +6 -5
  58. package/dist/lib/memory/providers.js +3 -4
  59. package/dist/lib/models/capabilities.d.ts +16 -1
  60. package/dist/lib/models/capabilities.js +55 -1
  61. package/dist/lib/observability/tracer.d.ts +8 -1
  62. package/dist/lib/observability/tracer.js +33 -5
  63. package/dist/lib/runtime/approvals.d.ts +6 -0
  64. package/dist/lib/runtime/native/agentLoop.d.ts +18 -1
  65. package/dist/lib/runtime/native/agentLoop.js +105 -11
  66. package/dist/lib/runtime/native/checkpoint.d.ts +164 -0
  67. package/dist/lib/runtime/native/checkpoint.js +365 -0
  68. package/dist/lib/runtime/native/delegate.d.ts +141 -18
  69. package/dist/lib/runtime/native/delegate.js +235 -39
  70. package/dist/lib/runtime/native/interrupts.d.ts +101 -2
  71. package/dist/lib/runtime/native/interrupts.js +201 -1
  72. package/dist/lib/runtime/native/request.d.ts +13 -3
  73. package/dist/lib/runtime/native/request.js +28 -4
  74. package/dist/lib/runtime/native/telemetry.js +10 -15
  75. package/dist/lib/runtime/questions.d.ts +7 -4
  76. package/dist/lib/runtime/sessions.d.ts +1 -1
  77. package/dist/lib/runtime/syndicateTurn.d.ts +7 -1
  78. package/dist/lib/runtime/syndicateTurn.js +74 -22
  79. package/dist/lib/runtime/turnControl.d.ts +15 -0
  80. package/dist/lib/runtime/turnControl.js +10 -0
  81. package/dist/lib/storage/postgres/index.d.ts +6 -1
  82. package/dist/lib/storage/postgres/index.js +7 -2
  83. package/dist/lib/storage/postgres/taskQueue.d.ts +14 -2
  84. package/dist/lib/storage/postgres/taskQueue.js +20 -3
  85. package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
  86. package/dist/lib/storage/postgres/taskStore.js +74 -8
  87. package/dist/lib/syndicateSchema.d.ts +278 -0
  88. package/dist/lib/syndicateSchema.js +182 -15
  89. package/dist/lib/tools/auth.d.ts +7 -5
  90. package/dist/lib/tools/auth.js +12 -4
  91. package/dist/lib/tools/credentialEnv.d.ts +28 -0
  92. package/dist/lib/tools/credentialEnv.js +52 -0
  93. package/dist/lib/tools/credentialStore.js +9 -2
  94. package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
  95. package/dist/lib/tools/mcpToolFactory.js +179 -21
  96. package/dist/lib/tools/oauthHosts.d.ts +71 -0
  97. package/dist/lib/tools/oauthHosts.js +154 -0
  98. package/dist/lib/tools/oauthTools.d.ts +233 -0
  99. package/dist/lib/tools/oauthTools.js +421 -0
  100. package/dist/lib/tools/openapiTools.d.ts +4 -6
  101. package/dist/lib/tools/openapiTools.js +40 -36
  102. package/dist/lib/tools/taskTools.d.ts +30 -5
  103. package/dist/lib/tools/taskTools.js +74 -10
  104. package/dist/lib/workflow.d.ts +8 -4
  105. package/dist/lib/workflow.js +8 -4
  106. package/dist/lib/workflowConfig.d.ts +6 -0
  107. package/dist/scripts/a2a_server.d.ts +36 -0
  108. package/dist/scripts/a2a_server.js +122 -1
  109. package/dist/scripts/assistant_worker.d.ts +10 -0
  110. package/dist/scripts/assistant_worker.js +69 -19
  111. package/dist/scripts/doctor.d.ts +4 -1
  112. package/dist/scripts/doctor.js +5 -2
  113. package/dist/scripts/yaml_codemod.d.ts +52 -0
  114. package/dist/scripts/yaml_codemod.js +570 -0
  115. package/package.json +11 -7
  116. package/skills/briefs/melchizedek-author.md +7 -5
  117. package/skills/briefs/melchizedek-models.md +4 -3
  118. package/skills/briefs/melchizedek.md +1 -0
  119. package/skills/melchizedek/SKILL.md +1 -0
  120. package/skills/melchizedek-author/SKILL.md +7 -4
  121. package/skills/melchizedek-author/assets/minimal.yaml +13 -13
  122. package/skills/melchizedek-models/SKILL.md +6 -3
@@ -32,7 +32,10 @@
32
32
  * OPENAPI_CREDENTIAL_ENVS, when the operator sets it, is the exact list
33
33
  * of variables an `auth` may name. A credential is held by the tool,
34
34
  * never written to session state, and its value never appears in an
35
- * error the model reads.
35
+ * error the model reads. An `auth: { oauth2 }` sends an OAuth token
36
+ * instead, fetched per call: the run's user's own (authorization_code,
37
+ * through `ctx.accessToken` and the consent pause) or the server's own
38
+ * (client_credentials), lib/tools/oauthTools.ts, ADR 0112.
36
39
  * - THE SSRF GUARD. Every server URL the tools will call must be http(s)
37
40
  * and pass lib/net/addressGuard.ts: its literal rules at compile time
38
41
  * (offline), and the full check with DNS before each call (a name can
@@ -62,6 +65,10 @@ import { extname, resolve } from 'node:path';
62
65
  import { MAX_RESULT_CHARS as TOOL_RESULT_CHARS, capResult } from './tool.js';
63
66
  import { callOperation, hostProblem } from './openapi/call.js';
64
67
  import { MAX_SPEC_BYTES, operationNamed, parseOpenApiSpec } from './openapi/parse.js';
68
+ import { ToolCredentialError } from './auth.js';
69
+ import { readCredentialEnv } from './credentialEnv.js';
70
+ import { oauthTokenSource, tokenTransportProblem } from './oauthTools.js';
71
+ export { credentialEnvProblem } from './credentialEnv.js';
65
72
  export { namesTool, toSnake } from './openapi/parse.js';
66
73
  /** Characters a single API result may carry into the conversation: every tool's shared limit (lib/tools/tool.ts). */
67
74
  export const MAX_RESULT_CHARS = TOOL_RESULT_CHARS;
@@ -70,45 +77,17 @@ export const MAX_RESULT_CHARS = TOOL_RESULT_CHARS;
70
77
  * operationId (lib/compile.ts). Its value is the operationId.
71
78
  */
72
79
  export const OPENAPI_TOOL = Symbol.for('melchizedek.openapiTool');
73
- /** Prefixes and names of variables the framework itself reads: never an API credential. */
74
- const FRAMEWORK_ENV_PREFIXES = [
75
- 'A2A_', 'SUPABASE_', 'DATABASE_', 'MCP_', 'MODEL_', 'MEMORY_', 'OTEL_', 'TELEMETRY_', 'MELCHIZEDEK_',
76
- 'GOOGLE_', 'GEMINI_', 'ANTHROPIC_', 'OPENAI_', 'AZURE_', 'AWS_', 'XAI_', 'MOONSHOT_', 'OLLAMA_', 'WIKI_',
77
- 'ALLOW_', 'OPENAPI_',
78
- ];
79
- const FRAMEWORK_ENV_NAMES = new Set(['PUBLIC_URL', 'HOST', 'PORT', 'PATH', 'HOME', 'NODE_OPTIONS', 'WEB_EXTRACT_CHAR_LIMIT']);
80
- /**
81
- * Why an `auth` may not read this variable, or null. With
82
- * OPENAPI_CREDENTIAL_ENVS set, only the names it lists are allowed; without
83
- * it, anything but the framework's own variables is.
84
- */
85
- export function credentialEnvProblem(name, env = process.env) {
86
- const allow = env.OPENAPI_CREDENTIAL_ENVS?.split(',').map((s) => s.trim()).filter(Boolean);
87
- if (allow?.length) {
88
- return allow.includes(name) ? null : `${name} is not in OPENAPI_CREDENTIAL_ENVS (${allow.join(', ')})`;
89
- }
90
- if (FRAMEWORK_ENV_NAMES.has(name) || FRAMEWORK_ENV_PREFIXES.some((p) => name.startsWith(p))) {
91
- return `${name} is one of the framework's own settings and may not be sent to an API; give the API its own variable (or list it in OPENAPI_CREDENTIAL_ENVS)`;
92
- }
93
- return null;
94
- }
95
80
  /**
96
81
  * The credential an `auth` names, read from the environment once, at
97
82
  * compile. Throws (failing the compile) on a variable the allowlist refuses
98
83
  * or one that is not set; the message names the variable, never its value.
84
+ * An `oauth2` block is not read here: its token is fetched per call
85
+ * (oauthTokenSource, lib/tools/oauthTools.ts).
99
86
  */
100
87
  function credentialFor(auth, spec) {
101
88
  if (!auth)
102
89
  return undefined;
103
- const read = (env) => {
104
- const refused = credentialEnvProblem(env);
105
- if (refused)
106
- throw new Error(`openapi ${spec}: ${refused}`);
107
- const value = process.env[env]?.trim();
108
- if (!value)
109
- throw new Error(`openapi ${spec}: ${env} is not set (auth reads it from the environment)`);
110
- return value;
111
- };
90
+ const read = (env) => readCredentialEnv(env, `openapi ${spec}`);
112
91
  if (auth.bearer_env)
113
92
  return { kind: 'bearer', token: read(auth.bearer_env) };
114
93
  if (auth.api_key)
@@ -152,13 +131,28 @@ function readSpec(specPath, source) {
152
131
  closeSync(fd);
153
132
  }
154
133
  }
155
- /** An own Tool that calls one operation (lib/tools/openapi/call.ts), its result cut to MAX_RESULT_CHARS. */
156
- function operationTool(op, credential) {
134
+ /**
135
+ * An own Tool that calls one operation (lib/tools/openapi/call.ts), its
136
+ * result cut to MAX_RESULT_CHARS. With `oauth`, each call fetches its token
137
+ * first; a token that cannot be had is answered as `{ error }` naming the
138
+ * provider and what to do, never a value (and, for a user who has not
139
+ * granted it, the run's consent step has already asked).
140
+ */
141
+ function operationTool(op, credential, oauth) {
157
142
  const tool = {
158
143
  name: op.name,
159
144
  declaration: () => op.declaration,
160
145
  async execute(args, ctx) {
161
- return capResult(await callOperation(op, args ?? {}, { credential, signal: ctx?.signal }));
146
+ let sent = credential;
147
+ if (oauth) {
148
+ try {
149
+ sent = { kind: 'bearer', token: await oauth(ctx, op.baseUrl) };
150
+ }
151
+ catch (error) {
152
+ return { error: error instanceof ToolCredentialError ? error.message : 'The authorization for this API could not be obtained.' };
153
+ }
154
+ }
155
+ return capResult(await callOperation(op, args ?? {}, { credential: sent, signal: ctx?.signal }));
162
156
  },
163
157
  };
164
158
  Object.defineProperty(tool, OPENAPI_TOOL, { value: op.operationId });
@@ -199,7 +193,17 @@ export async function buildOpenApiOwnTools(entry, baseDir = process.cwd()) {
199
193
  if (problem)
200
194
  throw new Error(`openapi ${entry.spec}: ${problem}`);
201
195
  }
202
- return chosen.map((o) => operationTool(o, credential));
196
+ let oauth;
197
+ if (entry.auth?.oauth2 && chosen.length) {
198
+ // Every server the token goes to takes it over https (or loopback http).
199
+ for (const server of new Set(chosen.map((o) => o.baseUrl))) {
200
+ const problem = tokenTransportProblem(server);
201
+ if (problem)
202
+ throw new Error(`openapi ${entry.spec}: ${problem}`);
203
+ }
204
+ oauth = oauthTokenSource(entry.auth.oauth2, `openapi ${entry.spec}`, [...new Set(chosen.map((o) => o.baseUrl))], { allowPrivate: process.env.ALLOW_PRIVATE_OPENAPI === 'true' });
205
+ }
206
+ return chosen.map((o) => operationTool(o, credential, oauth));
203
207
  }
204
208
  /**
205
209
  * The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
@@ -10,7 +10,9 @@
10
10
  * a list of records with a status — so both live in one store:
11
11
  *
12
12
  * kind: todo the user's own tasks. open → done | cancelled.
13
- * kind: background a job for the worker. queued → running → done | failed.
13
+ * kind: background a job for the worker. queued → running → done | failed,
14
+ * and cancelled from queued or running (a running job's
15
+ * worker sees it at its next renewal and stops).
14
16
  *
15
17
  * The tools only WRITE the queue. They never run a job: a tool that
16
18
  * orchestrates agents is exactly what the tool-contract doctrine refuses
@@ -28,11 +30,15 @@
28
30
  * gitignored). Every operation re-reads the file, so the chat process
29
31
  * and the worker see each other's writes. It is SINGLE-USER: every
30
32
  * caller shares one list. Run ONE worker: the claim is a
31
- * read-modify-write, not a lock.
33
+ * read-modify-write, not a lock. A running job's step checkpoints
34
+ * (ADR 0113) sit in a sidecar, <store>.checkpoints.json, keyed by id;
35
+ * every write of the store drops those of jobs no longer running or
36
+ * queued.
32
37
  * - With Postgres (postgresStorage().taskQueue, migration 0009) each
33
38
  * caller has its own list, scoped by the caller the tool call carries
34
39
  * (the A2A server's scope key), and any number of workers claim jobs
35
- * with FOR UPDATE SKIP LOCKED under a renewed lease (ADR 0021).
40
+ * with FOR UPDATE SKIP LOCKED under a renewed lease (ADR 0021). The
41
+ * checkpoint is a column beside the record (migration 0014).
36
42
  *
37
43
  * SECURITY:
38
44
  * On the file store every caller of a shared endpoint shares one list: do
@@ -95,8 +101,13 @@ export interface TaskBackend {
95
101
  mutate<T>(owner: string, change: (store: TaskStore) => T): Promise<T>;
96
102
  /** The oldest queued background job of any owner, now running and leased. */
97
103
  claimNext(worker: WorkerLease): Promise<OwnedTask | null>;
98
- /** Keeps a claimed job leased while it runs. */
99
- renew(worker: WorkerLease, job: OwnedTask): Promise<void>;
104
+ /**
105
+ * Keeps a claimed job leased while it runs. Resolves false when the claim
106
+ * is gone (the job left `running`, for instance cancelled with
107
+ * task_update, or another worker holds it): the worker must stop the run.
108
+ * A backend that resolves void makes no such claim.
109
+ */
110
+ renew(worker: WorkerLease, job: OwnedTask): Promise<void | boolean>;
100
111
  /** Records a job's outcome; a record no longer running is left alone. */
101
112
  finish(job: OwnedTask, outcome: JobOutcome): Promise<void>;
102
113
  /** Jobs whose worker died: queued again, or failed after MAX_ATTEMPTS. */
@@ -104,6 +115,19 @@ export interface TaskBackend {
104
115
  requeued: string[];
105
116
  failed: string[];
106
117
  }>;
118
+ /**
119
+ * Durable runs (ADR 0113): stores the run's latest step checkpoint beside
120
+ * the job. Resolves false when the job is no longer this worker's running
121
+ * job (cancelled, finished, or re-leased): the run must stop.
122
+ *
123
+ * A checkpoint lives only while its job is `running` or `queued` (a job
124
+ * re-queued after an interruption keeps it, so the next claim resumes);
125
+ * any other status drops it, and so does a pruned or deleted record. It is
126
+ * kept beside the record, never in it, so task_get never shows it.
127
+ */
128
+ saveCheckpoint?(worker: WorkerLease, job: OwnedTask, checkpoint: object): Promise<boolean>;
129
+ /** The checkpoint a re-queued job left, or null. */
130
+ loadCheckpoint?(job: OwnedTask): Promise<object | null>;
107
131
  }
108
132
  /** Records kept at most; finished ones are pruned oldest-first to make room. */
109
133
  export declare const MAX_TASKS = 500;
@@ -112,6 +136,7 @@ export declare const MAX_RESULT_CHARS = 20000;
112
136
  /** A job interrupted this many times is marked failed instead of re-queued. */
113
137
  export declare const MAX_ATTEMPTS = 2;
114
138
  export declare function taskStorePath(): string;
139
+ export declare function taskCheckpointPath(storePath?: string): string;
115
140
  /**
116
141
  * Called once at worker start: a record left `running` means a worker died
117
142
  * mid-job. It goes back to the queue, or fails once it has been tried
@@ -10,7 +10,9 @@
10
10
  * a list of records with a status — so both live in one store:
11
11
  *
12
12
  * kind: todo the user's own tasks. open → done | cancelled.
13
- * kind: background a job for the worker. queued → running → done | failed.
13
+ * kind: background a job for the worker. queued → running → done | failed,
14
+ * and cancelled from queued or running (a running job's
15
+ * worker sees it at its next renewal and stops).
14
16
  *
15
17
  * The tools only WRITE the queue. They never run a job: a tool that
16
18
  * orchestrates agents is exactly what the tool-contract doctrine refuses
@@ -28,11 +30,15 @@
28
30
  * gitignored). Every operation re-reads the file, so the chat process
29
31
  * and the worker see each other's writes. It is SINGLE-USER: every
30
32
  * caller shares one list. Run ONE worker: the claim is a
31
- * read-modify-write, not a lock.
33
+ * read-modify-write, not a lock. A running job's step checkpoints
34
+ * (ADR 0113) sit in a sidecar, <store>.checkpoints.json, keyed by id;
35
+ * every write of the store drops those of jobs no longer running or
36
+ * queued.
32
37
  * - With Postgres (postgresStorage().taskQueue, migration 0009) each
33
38
  * caller has its own list, scoped by the caller the tool call carries
34
39
  * (the A2A server's scope key), and any number of workers claim jobs
35
- * with FOR UPDATE SKIP LOCKED under a renewed lease (ADR 0021).
40
+ * with FOR UPDATE SKIP LOCKED under a renewed lease (ADR 0021). The
41
+ * checkpoint is a column beside the record (migration 0014).
36
42
  *
37
43
  * SECURITY:
38
44
  * On the file store every caller of a shared endpoint shares one list: do
@@ -73,18 +79,58 @@ function readStore(path = taskStorePath()) {
73
79
  }
74
80
  return { version: 1, next_id: parsed.next_id, tasks: parsed.tasks };
75
81
  }
76
- function writeStore(store, path = taskStorePath()) {
82
+ /** Atomic JSON write: temp file + rename, so a reader never sees half a file. */
83
+ function writeJson(value, path) {
77
84
  mkdirSync(dirname(path), { recursive: true });
78
85
  const tmp = `${path}.${process.pid}.tmp`;
79
- writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`);
86
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
80
87
  renameSync(tmp, path);
81
88
  }
89
+ function writeStore(store, path = taskStorePath()) {
90
+ writeJson(store, path);
91
+ }
92
+ /** Statuses a job's checkpoint survives in. */
93
+ const CHECKPOINTED = ['running', 'queued'];
94
+ export function taskCheckpointPath(storePath = taskStorePath()) {
95
+ return `${storePath}.checkpoints.json`;
96
+ }
97
+ /** The sidecar, or empty when missing or unreadable (a checkpoint is an optimisation, never the record). */
98
+ function readCheckpoints(storePath) {
99
+ const path = taskCheckpointPath(storePath);
100
+ if (!existsSync(path))
101
+ return {};
102
+ try {
103
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
104
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
105
+ }
106
+ catch {
107
+ return {};
108
+ }
109
+ }
110
+ const keepsCheckpoint = (t) => !!t && t.kind === 'background' && CHECKPOINTED.includes(t.status);
111
+ /** Drops sidecar entries whose job is gone or no longer running/queued. */
112
+ function pruneCheckpoints(store, storePath) {
113
+ if (!existsSync(taskCheckpointPath(storePath)))
114
+ return;
115
+ const entries = readCheckpoints(storePath);
116
+ const byId = new Map(store.tasks.map((t) => [t.id, t]));
117
+ let changed = false;
118
+ for (const id of Object.keys(entries)) {
119
+ if (!keepsCheckpoint(byId.get(id))) {
120
+ delete entries[id];
121
+ changed = true;
122
+ }
123
+ }
124
+ if (changed)
125
+ writeJson(entries, taskCheckpointPath(storePath));
126
+ }
82
127
  /** Read, change, write — one synchronous step, so no await splits it. */
83
128
  function mutate(change) {
84
129
  const path = taskStorePath();
85
130
  const store = readStore(path);
86
131
  const out = change(store);
87
132
  writeStore(store, path);
133
+ pruneCheckpoints(store, path);
88
134
  return out;
89
135
  }
90
136
  const now = () => new Date().toISOString();
@@ -212,9 +258,26 @@ export const fileTaskBackend = {
212
258
  const job = claimNextJob();
213
259
  return job ? { ...job, owner: '' } : null;
214
260
  },
215
- renew: async () => { },
261
+ // No leases on the single-user file: the claim holds while the record is running.
262
+ renew: async (_worker, job) => readStore().tasks.find((t) => t.id === job.id)?.status === 'running',
216
263
  finish: async (job, outcome) => finishJob(job.id, outcome),
217
264
  recover: async () => recoverInterruptedJobs(),
265
+ saveCheckpoint: async (_worker, job, checkpoint) => {
266
+ const path = taskStorePath();
267
+ const record = readStore(path).tasks.find((t) => t.id === job.id);
268
+ if (!record || record.kind !== 'background' || record.status !== 'running')
269
+ return false;
270
+ const entries = readCheckpoints(path);
271
+ entries[job.id] = { checkpoint, at: now() };
272
+ writeJson(entries, taskCheckpointPath(path));
273
+ return true;
274
+ },
275
+ loadCheckpoint: async (job) => {
276
+ const path = taskStorePath();
277
+ if (!keepsCheckpoint(readStore(path).tasks.find((t) => t.id === job.id)))
278
+ return null;
279
+ return readCheckpoints(path)[job.id]?.checkpoint ?? null;
280
+ },
218
281
  };
219
282
  let activeBackend = fileTaskBackend;
220
283
  /** Plugs in where tasks live (postgresStorage().taskQueue); default the JSON file. */
@@ -318,7 +381,8 @@ export const taskGetContract = defineTool({
318
381
  export const taskUpdateContract = defineTool({
319
382
  name: 'task_update',
320
383
  description: 'Change a task: mark it done, cancel it, reopen it, or edit its title, notes, or due date. ' +
321
- 'For a background job, "cancelled" withdraws it before it runs and "queued" retries a failed or cancelled job. ' +
384
+ 'For a background job, "cancelled" withdraws it before it runs, or stops it if it is running now ' +
385
+ '(the worker stops at its next step and keeps no result); "queued" retries a failed or cancelled job. ' +
322
386
  'Call task_list first if you do not have the id.',
323
387
  schema: z.object({
324
388
  id: taskId,
@@ -340,9 +404,9 @@ export const taskUpdateContract = defineTool({
340
404
  return `Error: a ${task.kind} task cannot be set to "${status}". Allowed: ${allowed[task.kind].join(', ')}.`;
341
405
  }
342
406
  if (task.kind === 'background') {
343
- if (task.status === 'running') {
344
- return `Error: ${id} is running now. Wait for it to finish, then read it with task_get.`;
345
- }
407
+ // Cancelling a running job is allowed: its worker's next lease
408
+ // renewal or checkpoint sees the record left `running` and stops
409
+ // the run, and the worker's outcome is then left alone (applyFinish).
346
410
  if (status === 'queued' && !['failed', 'cancelled'].includes(task.status)) {
347
411
  return `Error: only a failed or cancelled job can be queued again; ${id} is ${task.status}.`;
348
412
  }
@@ -41,16 +41,20 @@
41
41
  * A delegated `yaml_reference` to a workflow syndicate is the whole graph,
42
42
  * walked under the subagent entry's name and description on a child
43
43
  * session (lib/compileNative.ts workflowSubagentOf); its answer is the
44
- * graph's last event's text. A nested workflow with an
45
- * `ask_user` node is refused by name (lib/compile.ts compileWorkflowSpec): a
46
- * pause inside a tool call cannot reach the caller (ADR 0028).
44
+ * graph's last event's text. A walk that pauses (an `ask_user` node, a
45
+ * gated agent node) leaves the call open, and the turn ends input-required
46
+ * with the agent path down to the node; the answer walks the graph again
47
+ * (lib/runtime/native/delegate.ts resumeWorkflowSubagent, ADR 0111).
47
48
  *
48
49
  * ── As a dispatch route or a workflow node (ADR 0106) ────────────────────
49
50
  * A `yaml_reference` to a workflow syndicate runs its whole graph there too,
50
51
  * on the child session filed under the entry's name, as a subagent does. A
51
52
  * route's answer is what the graph would answer as its own syndicate
52
53
  * (lib/runtime/syndicateTurn.ts); a node's is the nested walk's
53
- * (lib/workflow/turn.ts). A map over one is refused (lib/compile.ts).
54
+ * (lib/workflow/turn.ts). A map over one is refused (lib/compile.ts), and
55
+ * so are its pauses (an `ask_user` node, a gate): they cannot reach the
56
+ * turn from a route or a node yet (lib/compile.ts compileWorkflowSpec,
57
+ * loadNestedSyndicate).
54
58
  *
55
59
  * ── Approval gates (ADR 0098, ADR 0106) ──────────────────────────────────
56
60
  * A tool in a node agent's `require_approval`, and a skill script
@@ -41,16 +41,20 @@
41
41
  * A delegated `yaml_reference` to a workflow syndicate is the whole graph,
42
42
  * walked under the subagent entry's name and description on a child
43
43
  * session (lib/compileNative.ts workflowSubagentOf); its answer is the
44
- * graph's last event's text. A nested workflow with an
45
- * `ask_user` node is refused by name (lib/compile.ts compileWorkflowSpec): a
46
- * pause inside a tool call cannot reach the caller (ADR 0028).
44
+ * graph's last event's text. A walk that pauses (an `ask_user` node, a
45
+ * gated agent node) leaves the call open, and the turn ends input-required
46
+ * with the agent path down to the node; the answer walks the graph again
47
+ * (lib/runtime/native/delegate.ts resumeWorkflowSubagent, ADR 0111).
47
48
  *
48
49
  * ── As a dispatch route or a workflow node (ADR 0106) ────────────────────
49
50
  * A `yaml_reference` to a workflow syndicate runs its whole graph there too,
50
51
  * on the child session filed under the entry's name, as a subagent does. A
51
52
  * route's answer is what the graph would answer as its own syndicate
52
53
  * (lib/runtime/syndicateTurn.ts); a node's is the nested walk's
53
- * (lib/workflow/turn.ts). A map over one is refused (lib/compile.ts).
54
+ * (lib/workflow/turn.ts). A map over one is refused (lib/compile.ts), and
55
+ * so are its pauses (an `ask_user` node, a gate): they cannot reach the
56
+ * turn from a route or a node yet (lib/compile.ts compileWorkflowSpec,
57
+ * loadNestedSyndicate).
54
58
  *
55
59
  * ── Approval gates (ADR 0098, ADR 0106) ──────────────────────────────────
56
60
  * A tool in a node agent's `require_approval`, and a skill script
@@ -80,6 +80,12 @@ export interface PendingInput {
80
80
  payload?: unknown;
81
81
  /** JSON Schema a structured answer must satisfy, when the node declared one. */
82
82
  schema?: unknown;
83
+ /**
84
+ * For a question asked inside a delegated subagent (ADR 0110): the agents
85
+ * from the turn's own agent down to the one that asked, `node` last.
86
+ * Absent when the turn's own agent (or a workflow node) asked.
87
+ */
88
+ path?: string[];
83
89
  }
84
90
  /** ADK's function-call name for a workflow input request. */
85
91
  export declare const INPUT_REQUEST = "adk_request_input";
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Server } from 'node:http';
3
3
  import { createA2AApp } from '../lib/a2a/app.ts';
4
+ import type { SyndicateYamlConfig } from '../lib/loadSyndicate.ts';
5
+ import type { OAuthServerSetup } from '../lib/a2a/oauthSetup.ts';
6
+ import type { AuditSink } from '../lib/observability/audit.ts';
7
+ import type { CredentialRows } from '../lib/tools/credentialStore.ts';
4
8
  export { describeTurnError } from '../lib/a2a/executor.ts';
5
9
  /**
6
10
  * What a deployment adds on top of the environment-driven server: its own
@@ -15,4 +19,36 @@ export interface ServerExtensions {
15
19
  /** Any other createA2AApp option; wins over what the environment sets. */
16
20
  options?: Partial<Parameters<typeof createA2AApp>[0]>;
17
21
  }
22
+ /**
23
+ * The syndicates this server serves from FILES: the default (unless it is a
24
+ * registry row), each served id that is not a registry id, and, when every
25
+ * file is served (A2A_SERVED_AGENTS unset or *), every syndicate at the
26
+ * agents directory's root. The consent step's OAuth clients come from these
27
+ * only (ADR 0114): a registry row may name a provider they declare, never
28
+ * define one. A file that does not load is skipped with a warning (its route
29
+ * fails on its own).
30
+ */
31
+ export declare function servedFileSyndicates(syndicateName: string, servedAgents: string[] | undefined, registryAgents: string[] | undefined, warn?: (m: string) => void): SyndicateYamlConfig[];
32
+ /** The server's tool credentials, from the environment: what createA2AApp takes, and what to print. */
33
+ export interface ServerOAuth extends OAuthServerSetup {
34
+ toolCredentials?: OAuthServerSetup['toolCredentials'] & {
35
+ requireCallerIdentity?: boolean;
36
+ };
37
+ }
38
+ /**
39
+ * Tool credentials and the consent step for the served syndicates, from
40
+ * MELCHIZEDEK_CREDENTIAL_KEY, OAUTH_REDIRECT_URI, OAUTH_CALLBACK_IDENTITY
41
+ * and MELCHIZEDEK_OAUTH_HOSTS (lib/a2a/oauthSetup.ts). Throws, naming
42
+ * variables only, on a configuration that is unsafe or cannot work.
43
+ */
44
+ export declare function serverOAuth(options: {
45
+ syndicateName: string;
46
+ servedAgents?: string[];
47
+ registryAgents?: string[];
48
+ rows?: CredentialRows;
49
+ audit?: AuditSink;
50
+ env?: NodeJS.ProcessEnv;
51
+ fetch?: typeof fetch;
52
+ warn?: (m: string) => void;
53
+ }): ServerOAuth;
18
54
  export declare function startServer(syndicateName?: string, extensions?: ServerExtensions): Promise<Server>;
@@ -75,9 +75,24 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
75
75
  * running turn (default 20). Behind a session-mode
76
76
  * pooler (Supabase :5432) keep the two sums under its
77
77
  * client limit, per instance.
78
+ * MELCHIZEDEK_CREDENTIAL_KEY 32 random bytes (base64 or hex) that seal each user's
79
+ * third-party OAuth tokens (ADR 0072). Unset = no tool
80
+ * credentials. Rows live in Postgres with DATABASE_URL,
81
+ * else in process memory
82
+ * OAUTH_REDIRECT_URI the consent callback, exactly as registered at every
83
+ * provider (https://agents.example.com/oauth/callback);
84
+ * mounts the callback and lets a run ask a user to
85
+ * connect (ADR 0085). Needs MELCHIZEDEK_CREDENTIAL_KEY
86
+ * OAUTH_CALLBACK_IDENTITY required (default): the callback's browser must carry the
87
+ * flow's user's identity (A2A_AUTH=header behind a gateway);
88
+ * state: the single-use state nonce alone binds it (ADR 0114)
89
+ * MELCHIZEDEK_OAUTH_HOSTS provider=host,host;…: the hosts each provider's tokens may
90
+ * be sent to. Unset: authorization_code grants are refused,
91
+ * client_credentials allowed (ADR 0114)
78
92
  */
79
93
  import { randomBytes } from 'node:crypto';
80
- import { realpathSync, writeFileSync } from 'node:fs';
94
+ import { readdirSync, realpathSync, writeFileSync } from 'node:fs';
95
+ import path from 'node:path';
81
96
  import { pathToFileURL } from 'node:url';
82
97
  import { createA2AApp } from '../lib/a2a/app.js';
83
98
  import { callerTokens, firstOf, hashCallerToken, jwtIdentity, parseCallers, sharedSecret, trustedHeader, } from '../lib/a2a/identity.js';
@@ -90,6 +105,10 @@ import { postgresStorage } from '../lib/storage/postgres/index.js';
90
105
  import { isPlaceholderValue, loadEnv } from '../lib/loadEnv.js';
91
106
  import { flushTracing } from '../lib/observability/tracer.js';
92
107
  import { setLogLevel } from '../lib/runtime/logging.js';
108
+ import { loadSyndicate } from '../lib/loadSyndicate.js';
109
+ import { callbackIdentity, callbackIdentityProblem, oauthServerSetup } from '../lib/a2a/oauthSetup.js';
110
+ import { postgresCredentialRows } from '../lib/storage/postgres/credentialStore.js';
111
+ import { setOAuthHosts } from '../lib/tools/oauthHosts.js';
93
112
  // Re-exported for existing importers (tests/failNarration.test.ts).
94
113
  export { describeTurnError } from '../lib/a2a/executor.js';
95
114
  /** Minimum bearer-secret length below which the server warns. */
@@ -132,6 +151,87 @@ function fatal(message) {
132
151
  emit('error', `[A2A] ✗ FATAL: ${message}`);
133
152
  process.exit(1);
134
153
  }
154
+ /**
155
+ * The syndicates this server serves from FILES: the default (unless it is a
156
+ * registry row), each served id that is not a registry id, and, when every
157
+ * file is served (A2A_SERVED_AGENTS unset or *), every syndicate at the
158
+ * agents directory's root. The consent step's OAuth clients come from these
159
+ * only (ADR 0114): a registry row may name a provider they declare, never
160
+ * define one. A file that does not load is skipped with a warning (its route
161
+ * fails on its own).
162
+ */
163
+ export function servedFileSyndicates(syndicateName, servedAgents, registryAgents, warn = () => { }) {
164
+ const registry = new Set(registryAgents ?? []);
165
+ const files = new Set();
166
+ const add = (id) => {
167
+ if (id.startsWith('registry:') || registry.has(id))
168
+ return;
169
+ files.add(id.endsWith('.yaml') || id.endsWith('.yml') ? id : `${id}.yaml`);
170
+ };
171
+ add(syndicateName);
172
+ if (servedAgents) {
173
+ for (const id of servedAgents)
174
+ add(id);
175
+ }
176
+ else {
177
+ const agentsDir = path.resolve(process.env.MELCHIZEDEK_AGENTS_DIR ?? path.join(process.cwd(), 'config', 'agents'));
178
+ let names = [];
179
+ try {
180
+ names = readdirSync(agentsDir);
181
+ }
182
+ catch {
183
+ names = [];
184
+ }
185
+ for (const f of names.sort())
186
+ if ((f.endsWith('.yaml') || f.endsWith('.yml')) && f !== 'syndicateSchema.yaml')
187
+ files.add(f);
188
+ }
189
+ const out = [];
190
+ for (const file of files) {
191
+ try {
192
+ out.push(loadSyndicate(file, { shippedFallback: servedAgents !== undefined || file === syndicateName }));
193
+ }
194
+ catch (err) {
195
+ if (err?.code !== 'ENOENT')
196
+ warn(`OAuth setup skipped ${file}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`);
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ /**
202
+ * Tool credentials and the consent step for the served syndicates, from
203
+ * MELCHIZEDEK_CREDENTIAL_KEY, OAUTH_REDIRECT_URI, OAUTH_CALLBACK_IDENTITY
204
+ * and MELCHIZEDEK_OAUTH_HOSTS (lib/a2a/oauthSetup.ts). Throws, naming
205
+ * variables only, on a configuration that is unsafe or cannot work.
206
+ */
207
+ export function serverOAuth(options) {
208
+ const env = options.env ?? process.env;
209
+ const identity = callbackIdentity(env);
210
+ const setup = oauthServerSetup({
211
+ configs: servedFileSyndicates(options.syndicateName, options.servedAgents, options.registryAgents, options.warn),
212
+ load: (ref) => loadSyndicate(ref),
213
+ env,
214
+ ...(options.rows ? { rows: options.rows } : {}),
215
+ ...(options.audit ? { audit: options.audit } : {}),
216
+ allowPrivate: env.ALLOW_PRIVATE_OPENAPI === 'true' || env.ALLOW_PRIVATE_MCP === 'true',
217
+ ...(options.fetch ? { fetch: options.fetch } : {}),
218
+ });
219
+ if (!setup.toolCredentials?.consent)
220
+ return setup;
221
+ const warnings = [...setup.warnings];
222
+ const refused = callbackIdentityProblem(env);
223
+ if (identity === 'state') {
224
+ warnings.push('OAUTH_CALLBACK_IDENTITY=state: the single-use state alone binds a consent callback to its user, so a forwarded authorization link can connect the forwarder\'s account to the user who was asked.');
225
+ }
226
+ else if (refused) {
227
+ warnings.push(`${refused.charAt(0).toUpperCase()}${refused.slice(1)}.`);
228
+ }
229
+ return {
230
+ ...setup,
231
+ warnings,
232
+ toolCredentials: { ...setup.toolCredentials, requireCallerIdentity: identity === 'required' },
233
+ };
234
+ }
135
235
  export async function startServer(syndicateName = 'syndicate.yaml', extensions = {}) {
136
236
  loadEnv(import.meta.url);
137
237
  // Conversation content stays out of stdout unless the operator opts in:
@@ -319,6 +419,25 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
319
419
  if (metricsToken && (isPlaceholderValue(metricsToken) || metricsToken.length < 24)) {
320
420
  fatal('A2A_METRICS_TOKEN must be a long random value (openssl rand -hex 32).');
321
421
  }
422
+ // ── Tool credentials and the OAuth consent step (ADR 0072, 0085, 0114) ────
423
+ let oauth;
424
+ try {
425
+ // An extension's allowlist wins over the environment's here too, as createA2AApp applies it.
426
+ if (extensions.options?.oauthHosts)
427
+ setOAuthHosts(extensions.options.oauthHosts);
428
+ oauth = serverOAuth({
429
+ syndicateName,
430
+ servedAgents,
431
+ registryAgents,
432
+ ...(pgStorage ? { rows: postgresCredentialRows(pgStorage.pool), audit: pgStorage.audit } : {}),
433
+ warn: (m) => emit('warn', `[A2A] ⚠ ${m}`),
434
+ });
435
+ }
436
+ catch (err) {
437
+ fatal(err instanceof Error ? err.message : String(err));
438
+ }
439
+ for (const w of oauth.warnings)
440
+ emit('warn', `[A2A] ⚠ ${w}`);
322
441
  let built;
323
442
  try {
324
443
  built = await createA2AApp({
@@ -345,6 +464,7 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
345
464
  keyMode,
346
465
  ...(pgStorage ? { storage: pgStorage } : {}),
347
466
  ...(policy ? { policy } : {}),
467
+ ...(oauth.toolCredentials ? { toolCredentials: oauth.toolCredentials } : {}),
348
468
  metricsToken,
349
469
  log: (m) => emit('info', `[A2A] ${m}`),
350
470
  warn: (m) => emit('warn', `[A2A] ⚠ ${m}`),
@@ -388,6 +508,7 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
388
508
  }
389
509
  if (budgetLabel)
390
510
  emit('info', `[A2A] budgets ${budgetLabel}`);
511
+ emit('info', `[A2A] oauth ${oauth.summary}`);
391
512
  if (metricsToken)
392
513
  emit('info', `[A2A] metrics ${base}/metrics (bearer A2A_METRICS_TOKEN)`);
393
514
  emit('info', `[A2A] health ${base}/healthz ${base}/readyz`);
@@ -21,6 +21,16 @@
21
21
  * instead of abandoning it while it keeps spending. Any syndicate and any
22
22
  * subagent in it can serve as the worker.
23
23
  *
24
+ * A run is durable (ADR 0113): runDurableTurn (lib/runtime/native/
25
+ * checkpoint.ts) checkpoints the run's sessions at every step boundary
26
+ * beside the job (TaskBackend.saveCheckpoint), and a job claimed again
27
+ * after its worker stopped resumes from its last checkpoint instead of
28
+ * starting over. SIGTERM aborts the step in flight and puts the job back
29
+ * in the queue with its checkpoint; the next worker finishes it. A job
30
+ * cancelled while it runs (task_update) loses its claim: the next lease
31
+ * renewal or checkpoint save sees it, and the run is aborted without
32
+ * writing a result over the cancellation.
33
+ *
24
34
  * Usage:
25
35
  * npm run assistant:worker poll every 30 s until Ctrl-C
26
36
  * npm run assistant:worker -- --once drain the queue, then exit (cron)