doer-agent 0.9.0 → 0.9.2

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.
@@ -13,6 +13,11 @@ function toTomlStringLiteral(value) {
13
13
  function toTomlStringArray(values) {
14
14
  return `[${values.map((value) => toTomlStringLiteral(value)).join(", ")}]`;
15
15
  }
16
+ function toTomlStringMap(values) {
17
+ return `{ ${Object.entries(values)
18
+ .map(([key, value]) => `${toTomlStringLiteral(key)} = ${toTomlStringLiteral(value)}`)
19
+ .join(", ")} }`;
20
+ }
16
21
  function buildMcpServerConfigArgs(args) {
17
22
  const serverName = args.serverName.trim();
18
23
  const configArgs = [
@@ -28,6 +33,25 @@ function buildMcpServerConfigArgs(args) {
28
33
  }
29
34
  return configArgs;
30
35
  }
36
+ function buildRemoteMcpServerConfigArgs(args) {
37
+ const prefix = `mcp_servers.${args.serverName.trim()}`;
38
+ const configArgs = [
39
+ "--config",
40
+ `${prefix}.url=${toTomlStringLiteral(args.url)}`,
41
+ "--config",
42
+ `${prefix}.enabled=${args.enabled === false ? "false" : "true"}`,
43
+ ];
44
+ if (args.bearerTokenEnvVar?.trim()) {
45
+ configArgs.push("--config", `${prefix}.bearer_token_env_var=${toTomlStringLiteral(args.bearerTokenEnvVar.trim())}`);
46
+ }
47
+ if (Object.keys(args.httpHeaders ?? {}).length > 0) {
48
+ configArgs.push("--config", `${prefix}.http_headers=${toTomlStringMap(args.httpHeaders ?? {})}`);
49
+ }
50
+ if (Object.keys(args.envHttpHeaders ?? {}).length > 0) {
51
+ configArgs.push("--config", `${prefix}.env_http_headers=${toTomlStringMap(args.envHttpHeaders ?? {})}`);
52
+ }
53
+ return configArgs;
54
+ }
31
55
  function hasDirectCodexBinary() {
32
56
  const result = spawnSync("bash", ["-lc", "command -v codex >/dev/null 2>&1"], {
33
57
  stdio: "ignore",
@@ -124,10 +148,22 @@ export function buildCustomMcpConfigArgs(servers) {
124
148
  const seenNames = new Set();
125
149
  for (const server of servers) {
126
150
  const serverName = server.name.trim();
127
- if (!server.enabled || !serverName || !server.command.trim() || reservedNames.has(serverName) || seenNames.has(serverName)) {
151
+ const hasEndpoint = server.transport === "streamable_http" ? server.url.trim() : server.command.trim();
152
+ if (!server.enabled || !serverName || !hasEndpoint || reservedNames.has(serverName) || seenNames.has(serverName)) {
128
153
  continue;
129
154
  }
130
155
  seenNames.add(serverName);
156
+ if (server.transport === "streamable_http") {
157
+ configArgs.push(...buildRemoteMcpServerConfigArgs({
158
+ serverName,
159
+ url: server.url,
160
+ bearerTokenEnvVar: server.bearerTokenEnvVar,
161
+ httpHeaders: Object.fromEntries(server.httpHeaders.map((header) => [header.key, header.value])),
162
+ envHttpHeaders: Object.fromEntries(server.envHttpHeaders.map((header) => [header.key, header.value])),
163
+ enabled: true,
164
+ }));
165
+ continue;
166
+ }
131
167
  configArgs.push(...buildMcpServerConfigArgs({
132
168
  serverName,
133
169
  command: server.command,
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildCustomMcpConfigArgs } from "./agent-codex-cli.js";
4
+ test("buildCustomMcpConfigArgs builds streamable HTTP MCP overrides", () => {
5
+ const args = buildCustomMcpConfigArgs([{
6
+ name: "remote_docs",
7
+ transport: "streamable_http",
8
+ command: "",
9
+ args: [],
10
+ env: [],
11
+ url: "https://example.com/mcp",
12
+ bearerTokenEnvVar: "MCP_TOKEN",
13
+ httpHeaders: [{ key: "X-Region", value: "seoul" }],
14
+ envHttpHeaders: [{ key: "X-API-Key", value: "MCP_API_KEY" }],
15
+ enabled: true,
16
+ }]);
17
+ assert.deepEqual(args, [
18
+ "--config", 'mcp_servers.remote_docs.url="https://example.com/mcp"',
19
+ "--config", "mcp_servers.remote_docs.enabled=true",
20
+ "--config", 'mcp_servers.remote_docs.bearer_token_env_var="MCP_TOKEN"',
21
+ "--config", 'mcp_servers.remote_docs.http_headers={ "X-Region" = "seoul" }',
22
+ "--config", 'mcp_servers.remote_docs.env_http_headers={ "X-API-Key" = "MCP_API_KEY" }',
23
+ ]);
24
+ });
25
+ test("buildCustomMcpConfigArgs keeps stdio MCP overrides", () => {
26
+ const args = buildCustomMcpConfigArgs([{
27
+ name: "local_tools",
28
+ transport: "stdio",
29
+ command: "npx",
30
+ args: ["-y", "local-mcp"],
31
+ env: [{ key: "LOCAL_TOKEN", value: "secret" }],
32
+ url: "",
33
+ bearerTokenEnvVar: "",
34
+ httpHeaders: [],
35
+ envHttpHeaders: [],
36
+ enabled: true,
37
+ }]);
38
+ assert.deepEqual(args, [
39
+ "--config", 'mcp_servers.local_tools.command="npx"',
40
+ "--config", 'mcp_servers.local_tools.args=["-y", "local-mcp"]',
41
+ "--config", "mcp_servers.local_tools.enabled=true",
42
+ "--config", 'mcp_servers.local_tools.env.LOCAL_TOKEN="secret"',
43
+ ]);
44
+ });
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildNatsConnectionOptions } from "./agent-jetstream.js";
4
+ import { heartbeatAgentSession } from "./agent-runtime-io.js";
5
+ test("NATS connection detects stale networks quickly and keeps reconnecting", () => {
6
+ const options = buildNatsConnectionOptions({
7
+ servers: ["tls://nats.example.com:4222"],
8
+ token: "secret",
9
+ });
10
+ assert.equal(options.pingInterval, 2_000);
11
+ assert.equal(options.maxPingOut, 2);
12
+ assert.equal(options.maxReconnectAttempts, -1);
13
+ assert.equal(options.reconnectTimeWait, 1_000);
14
+ assert.equal(options.token, "secret");
15
+ });
16
+ test("heartbeat stops waiting when a NATS flush stalls", async () => {
17
+ const startedAt = Date.now();
18
+ await assert.rejects(heartbeatAgentSession({
19
+ nc: { flush: () => new Promise(() => undefined) },
20
+ serverBaseUrl: "https://doer.example.com",
21
+ userId: "user-1",
22
+ agentToken: "token-1",
23
+ timeoutMs: 20,
24
+ postJson: async () => ({ ok: true }),
25
+ }), /nats flush timed out after 20ms/);
26
+ assert.ok(Date.now() - startedAt < 500);
27
+ });
28
+ test("heartbeat forwards its timeout to the HTTP probe", async () => {
29
+ let observedTimeout;
30
+ await heartbeatAgentSession({
31
+ nc: { flush: async () => undefined },
32
+ serverBaseUrl: "https://doer.example.com",
33
+ userId: "user-1",
34
+ agentToken: "token-1",
35
+ timeoutMs: 3_000,
36
+ postJson: async (_url, _body, options) => {
37
+ observedTimeout = options?.timeoutMs;
38
+ return { ok: true };
39
+ },
40
+ });
41
+ assert.equal(observedTimeout, 3_000);
42
+ });
@@ -1,4 +1,20 @@
1
1
  import { AckPolicy, connect, DeliverPolicy, JSONCodec, RetentionPolicy, StorageType, } from "nats";
2
+ const NATS_PING_INTERVAL_MS = 2_000;
3
+ const NATS_MAX_PING_OUT = 2;
4
+ const NATS_RECONNECT_TIME_WAIT_MS = 1_000;
5
+ const NATS_RECONNECT_JITTER_MS = 250;
6
+ export function buildNatsConnectionOptions(args) {
7
+ return {
8
+ servers: args.servers,
9
+ ...(args.token ? { token: args.token } : {}),
10
+ pingInterval: NATS_PING_INTERVAL_MS,
11
+ maxPingOut: NATS_MAX_PING_OUT,
12
+ maxReconnectAttempts: -1,
13
+ reconnectTimeWait: NATS_RECONNECT_TIME_WAIT_MS,
14
+ reconnectJitter: NATS_RECONNECT_JITTER_MS,
15
+ reconnectJitterTLS: NATS_RECONNECT_JITTER_MS,
16
+ };
17
+ }
2
18
  export function normalizeNatsServers(value) {
3
19
  if (!Array.isArray(value)) {
4
20
  return [];
@@ -55,7 +71,7 @@ async function initJetStreamContext(args) {
55
71
  const stream = `DOER_AGENT_EVENTS_${sanitized}`;
56
72
  const subject = `doer.agent.events.${sanitized}`;
57
73
  const durable = `doer-agent-uploader-${sanitized}`;
58
- const nc = await connect(args.token ? { servers: args.servers, token: args.token } : { servers: args.servers });
74
+ const nc = await connect(buildNatsConnectionOptions({ servers: args.servers, token: args.token }));
59
75
  const jsm = await nc.jetstreamManager();
60
76
  await ensureJetStreamInfra({ jsm, stream, subject, durable });
61
77
  void (async () => {
@@ -1,9 +1,19 @@
1
- export async function postJson(url, body) {
2
- const res = await fetch(url, {
3
- method: "POST",
4
- headers: { "Content-Type": "application/json" },
5
- body: JSON.stringify(body),
6
- });
1
+ export async function postJson(url, body, options = {}) {
2
+ let res;
3
+ try {
4
+ res = await fetch(url, {
5
+ method: "POST",
6
+ headers: { "Content-Type": "application/json" },
7
+ body: JSON.stringify(body),
8
+ ...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
9
+ });
10
+ }
11
+ catch (error) {
12
+ if (options.timeoutMs && error instanceof Error && error.name === "TimeoutError") {
13
+ throw new Error(`request timed out after ${options.timeoutMs}ms`);
14
+ }
15
+ throw error;
16
+ }
7
17
  const text = await res.text();
8
18
  let data = {};
9
19
  if (text) {
@@ -104,9 +114,22 @@ export function createEventPersistenceHelpers(args) {
104
114
  };
105
115
  }
106
116
  export async function heartbeatAgentSession(args) {
107
- await args.nc.flush();
117
+ let timeout;
118
+ try {
119
+ await Promise.race([
120
+ args.nc.flush(),
121
+ new Promise((_, reject) => {
122
+ timeout = setTimeout(() => reject(new Error(`nats flush timed out after ${args.timeoutMs}ms`)), args.timeoutMs);
123
+ }),
124
+ ]);
125
+ }
126
+ finally {
127
+ if (timeout) {
128
+ clearTimeout(timeout);
129
+ }
130
+ }
108
131
  await args.postJson(`${args.serverBaseUrl}/api/agent/heartbeat`, {
109
132
  userId: args.userId,
110
133
  agentToken: args.agentToken,
111
- });
134
+ }, { timeoutMs: args.timeoutMs });
112
135
  }
@@ -172,7 +172,7 @@ function normalizeMcpServerName(value) {
172
172
  if (!trimmed || !/^[A-Za-z0-9_-]+$/.test(trimmed)) {
173
173
  return null;
174
174
  }
175
- if (trimmed === "doer_daemon" || trimmed === "doer_mobile") {
175
+ if (trimmed === "doer_daemon" || trimmed === "doer_mobile" || trimmed === "doer_threads") {
176
176
  return null;
177
177
  }
178
178
  return trimmed;
@@ -185,14 +185,38 @@ function normalizeStringArray(value) {
185
185
  .map((item) => (typeof item === "string" ? item.replace(/\r/g, "") : null))
186
186
  .filter((item) => item !== null && item.trim().length > 0);
187
187
  }
188
+ function normalizeMcpHeaderEntries(value) {
189
+ if (!Array.isArray(value)) {
190
+ return [];
191
+ }
192
+ const entries = [];
193
+ const seenKeys = new Set();
194
+ for (const item of value) {
195
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
196
+ continue;
197
+ }
198
+ const raw = item;
199
+ const key = typeof raw.key === "string" ? raw.key.trim() : "";
200
+ if (!key || typeof raw.value !== "string" || seenKeys.has(key)) {
201
+ continue;
202
+ }
203
+ seenKeys.add(key);
204
+ entries.push({ key, value: raw.value.replace(/\r/g, "") });
205
+ }
206
+ return entries;
207
+ }
188
208
  function normalizeAgentMcpServer(value) {
189
209
  if (!value || typeof value !== "object" || Array.isArray(value)) {
190
210
  return null;
191
211
  }
192
212
  const raw = value;
193
213
  const name = normalizeMcpServerName(raw.name);
214
+ const transport = raw.transport === "streamable_http" || (typeof raw.url === "string" && raw.url.trim())
215
+ ? "streamable_http"
216
+ : "stdio";
194
217
  const command = typeof raw.command === "string" ? raw.command.trim() : "";
195
- if (!name || !command) {
218
+ const url = typeof raw.url === "string" ? raw.url.trim() : "";
219
+ if (!name || (transport === "stdio" ? !command : !url)) {
196
220
  return null;
197
221
  }
198
222
  const env = [];
@@ -208,9 +232,14 @@ function normalizeAgentMcpServer(value) {
208
232
  }
209
233
  return {
210
234
  name,
235
+ transport,
211
236
  command,
212
237
  args: normalizeStringArray(raw.args),
213
238
  env,
239
+ url,
240
+ bearerTokenEnvVar: typeof raw.bearerTokenEnvVar === "string" ? raw.bearerTokenEnvVar.trim() : "",
241
+ httpHeaders: normalizeMcpHeaderEntries(raw.httpHeaders),
242
+ envHttpHeaders: normalizeMcpHeaderEntries(raw.envHttpHeaders),
214
243
  enabled: raw.enabled !== false,
215
244
  };
216
245
  }
@@ -386,12 +415,17 @@ export async function toAgentSettingsPublic(args) {
386
415
  mcp: {
387
416
  servers: args.config.mcp.servers.map((server) => ({
388
417
  name: server.name,
418
+ transport: server.transport,
389
419
  command: server.command,
390
420
  args: [...server.args],
391
421
  env: server.env.map((variable) => ({
392
422
  key: variable.key,
393
423
  value: variable.value,
394
424
  })),
425
+ url: server.url,
426
+ bearerTokenEnvVar: server.bearerTokenEnvVar,
427
+ httpHeaders: server.httpHeaders.map((header) => ({ ...header })),
428
+ envHttpHeaders: server.envHttpHeaders.map((header) => ({ ...header })),
395
429
  enabled: server.enabled,
396
430
  })),
397
431
  },
package/dist/agent.js CHANGED
@@ -28,7 +28,8 @@ const AGENT_PROJECT_DIR = path.join(AGENT_MODULE_DIR, "..");
28
28
  const AGENT_PACKAGE_JSON_PATH = path.join(AGENT_PROJECT_DIR, "package.json");
29
29
  const BUNDLED_SKILLS_ROOT = path.join(AGENT_PROJECT_DIR, "runtime", "skills");
30
30
  const HEARTBEAT_INTERVAL_MS = 5_000;
31
- const HEARTBEAT_FAILURE_THRESHOLD = 3;
31
+ const HEARTBEAT_FAILURE_THRESHOLD = 2;
32
+ const HEARTBEAT_REQUEST_TIMEOUT_MS = 3_000;
32
33
  const codexAppEventCodec = StringCodec();
33
34
  let activeTaskLogContext = null;
34
35
  let workspaceRootOverride = null;
@@ -187,6 +188,7 @@ const eventPersistenceHelpers = createEventPersistenceHelpers({
187
188
  const heartbeatSession = async (args) => {
188
189
  await heartbeatAgentSession({
189
190
  ...args,
191
+ timeoutMs: HEARTBEAT_REQUEST_TIMEOUT_MS,
190
192
  postJson,
191
193
  });
192
194
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",