Skip to content

Commit ceca307

Browse files
authored
Add OpenAI Agent Worker (#4)
<h2>OpenAI Agent Worker</h2> <h3>Summary</h3> <p>Introduces a new ACP agent (<code>openai-agent</code>) that bridges the Agent Client Protocol to any OpenAI Chat Completions API-compatible endpoint. This enables stdio Bus users to interact with OpenAI, AWS Bedrock, Azure OpenAI, Ollama, vLLM, LiteLLM, and other compatible providers through a unified ACP interface.</p> <h3>Motivation</h3> <p>The stdio Bus Workers Registry currently supports agents from the ACP Registry, but lacks a built-in way to connect to OpenAI-compatible APIs. This PR fills that gap by providing a universal agent that works with any <code>/chat/completions</code> endpoint.</p> <h3>Changes</h3> <p><strong>New Worker: <code>workers-registry/openai-agent/</code></strong></p> <ul> <li>Full ACP protocol implementation (<code>initialize</code>, <code>newSession</code>, <code>prompt</code>, <code>cancel</code>)</li> <li>SSE streaming support for real-time token delivery</li> <li>Per-session conversation history for multi-turn dialogues</li> <li>Native <code>fetch()</code> for HTTP requests (zero external HTTP dependencies)</li> <li>Graceful shutdown with <code>AbortController</code> for in-flight requests</li> </ul> <p><strong>Registry Launcher Enhancement</strong></p> <ul> <li>Added <code>--custom-agents</code> CLI argument and <code>ACP_CUSTOM_AGENTS_PATH</code> env var support</li> <li>Enables loading custom agent definitions from external JSON files</li> </ul> <p><strong>Infrastructure</strong></p> <ul> <li>Registered <code>openai-agent</code> in <code>launch/index.ts</code> for <code>npx @stdiobus/workers-registry openai-agent</code></li> <li>Added exports to root <code>package.json</code></li> </ul> <h3>Configuration</h3> <p>Environment variables:</p> Variable | Default | Description -- | -- | -- OPENAI_BASE_URL | https://api.openai.com/v1 | API endpoint OPENAI_API_KEY | — | Authentication key OPENAI_MODEL | gpt-4o | Model identifier OPENAI_SYSTEM_PROMPT | — | Optional system prompt OPENAI_MAX_TOKENS | — | Optional token limit OPENAI_TEMPERATURE | — | Optional temperature <h3>Testing</h3> <ul> <li>5 unit test suites covering config, session, SSE parsing, agent, and client</li> <li>6 property-based test suites (100+ iterations each) for protocol invariants</li> <li>All existing tests pass</li> </ul> <pre><code class="language-bash">cd workers-registry/openai-agent &amp;&amp; npm test </code></pre> <h3>How to Verify</h3> <ol> <li><p>Build the worker:</p> <pre><code class="language-bash">cd workers-registry/openai-agent npm install &amp;&amp; npm run build </code></pre> </li> <li><p>Test with a raw NDJSON message:</p> <pre><code class="language-bash">export OPENAI_API_KEY="sk-..." echo '{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"clientInfo":{"name":"test","version":"1.0"}}}' | node dist/index.js </code></pre> </li> </ol> <h3>Breaking Changes</h3> <p>None. This is an additive feature.</p> <h3>Checklist</h3> <ul> <li><input checked="" disabled="" type="checkbox"> Code compiles (<code>npm run build</code>)</li> <li><input checked="" disabled="" type="checkbox"> Tests pass (<code>npm test</code>)</li> <li><input checked="" disabled="" type="checkbox"> Protocol compliance (stdout = NDJSON only, stderr = logs)</li> <li><input checked="" disabled="" type="checkbox"> Documentation added (<code>README.md</code>)</li> <li><input checked="" disabled="" type="checkbox"> No secrets in code</li> </ul> </body></html>
2 parents 4c8163b + bf5ceb6 commit ceca307

42 files changed

Lines changed: 7817 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,3 +777,4 @@ cython_debug/
777777
.idea/
778778
sandbox
779779
/launch/
780+
.ai

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stdiobus/workers-registry",
3-
"version": "1.4.6",
3+
"version": "1.4.12",
44
"description": "Worker implementations for stdio Bus kernel - ACP, MCP, and protocol bridges",
55
"type": "module",
66
"main": "./out/dist/workers-registry/acp-registry/index.js",
@@ -44,6 +44,10 @@
4444
"types": "./out/tsc/workers-registry/mcp-echo-server/index.d.ts"
4545
},
4646
"./workers/mcp-to-acp-proxy": "./out/dist/workers-registry/mcp-to-acp-proxy/proxy.js",
47+
"./workers/openai-agent": {
48+
"import": "./out/dist/workers-registry/openai-agent/index.js",
49+
"types": "./out/tsc/workers-registry/openai-agent/src/index.d.ts"
50+
},
4751
"./package.json": "./package.json"
4852
},
4953
"bin": {

workers-registry/acp-registry/index.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ const __dirname = dirname(__filename);
3838
const DEFAULT_CONFIG_FILE = 'acp-registry-config.json';
3939

4040
function ensureDefaultConfigArg() {
41-
const hasExplicitConfig = process.argv.length > 2 && process.argv[2] && !process.argv[2].startsWith('-');
42-
if (hasExplicitConfig) {
43-
return;
44-
}
41+
const userArgs = process.argv.slice(2);
4542

46-
const defaultConfigPath = join(__dirname, DEFAULT_CONFIG_FILE);
47-
process.argv.splice(2, 0, defaultConfigPath);
43+
// Check if a positional config path was provided (first non-flag argument)
44+
const hasExplicitConfig = userArgs.length > 0 && userArgs[0] && !userArgs[0].startsWith('-');
45+
if (!hasExplicitConfig) {
46+
const defaultConfigPath = join(__dirname, DEFAULT_CONFIG_FILE);
47+
process.argv.splice(2, 0, defaultConfigPath);
48+
}
4849
}
4950

5051
ensureDefaultConfigArg();

workers-registry/acp-worker/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
"agent",
2222
"protocol"
2323
],
24-
"author": "",
24+
"author": {
25+
"name": "Raman Marozau",
26+
"email": "raman@worktif.com",
27+
"url": "https://worktif.com"
28+
},
2529
"license": "Apache-2.0",
2630
"engines": {
2731
"node": ">=18.0.0"

workers-registry/acp-worker/src/registry-launcher/config/config.property.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,11 @@ describe('Config Parsing Property Tests', () => {
204204
/**
205205
* Arbitrary for generating extra fields that should be ignored.
206206
*/
207+
const knownConfigKeys = ['registryUrl', 'apiKeysPath', 'shutdownTimeoutSec', 'customAgentsPath'];
208+
207209
const extraFieldsArb = fc.dictionary(
208210
fc.string({ minLength: 1, maxLength: 20 }).filter(
209-
(s) => s !== 'registryUrl' && s !== 'shutdownTimeoutSec',
211+
(s) => !knownConfigKeys.includes(s),
210212
),
211213
fc.jsonValue(),
212214
);
@@ -229,10 +231,7 @@ describe('Config Parsing Property Tests', () => {
229231

230232
// Verify extra fields are not present in result
231233
const resultKeys = Object.keys(result);
232-
const onlyKnownFields =
233-
resultKeys.length === 2 &&
234-
resultKeys.includes('registryUrl') &&
235-
resultKeys.includes('shutdownTimeoutSec');
234+
const onlyKnownFields = resultKeys.every((key) => knownConfigKeys.includes(key));
236235

237236
return knownFieldsCorrect && onlyKnownFields;
238237
},

workers-registry/acp-worker/src/registry-launcher/config/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ const ENV_REGISTRY_URL = 'ACP_REGISTRY_URL';
4545
*/
4646
const ENV_API_KEYS_PATH = 'ACP_API_KEYS_PATH';
4747

48+
/**
49+
* Environment variable name for custom agents file path override.
50+
*/
51+
const ENV_CUSTOM_AGENTS_PATH = 'ACP_CUSTOM_AGENTS_PATH';
52+
4853
/**
4954
* Log a warning message to stderr with ISO 8601 timestamp.
5055
* @param message - Warning message to log
@@ -116,6 +121,15 @@ function parseConfigObject(obj: unknown): LauncherConfig {
116121
}
117122
}
118123

124+
// Parse customAgentsPath
125+
if ('customAgentsPath' in rawConfig) {
126+
if (isNonEmptyString(rawConfig.customAgentsPath)) {
127+
config.customAgentsPath = rawConfig.customAgentsPath;
128+
} else {
129+
logWarning('Config field "customAgentsPath" is not a valid string, ignoring');
130+
}
131+
}
132+
119133
return config;
120134
}
121135

@@ -129,6 +143,7 @@ function parseConfigObject(obj: unknown): LauncherConfig {
129143
function applyEnvironmentOverrides(config: LauncherConfig): LauncherConfig {
130144
const envRegistryUrl = process.env[ENV_REGISTRY_URL];
131145
const envApiKeysPath = process.env[ENV_API_KEYS_PATH];
146+
const envCustomAgentsPath = process.env[ENV_CUSTOM_AGENTS_PATH];
132147

133148
const overrides: Partial<LauncherConfig> = {};
134149

@@ -140,6 +155,10 @@ function applyEnvironmentOverrides(config: LauncherConfig): LauncherConfig {
140155
overrides.apiKeysPath = envApiKeysPath;
141156
}
142157

158+
if (isNonEmptyString(envCustomAgentsPath)) {
159+
overrides.customAgentsPath = envCustomAgentsPath;
160+
}
161+
143162
return {
144163
...config,
145164
...overrides,

workers-registry/acp-worker/src/registry-launcher/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface LauncherConfig {
3131
apiKeysPath: string;
3232
/** Agent shutdown timeout in seconds (default: 5) */
3333
shutdownTimeoutSec: number;
34+
/** Path to custom agents JSON file (optional, loaded via --custom-agents CLI arg) */
35+
customAgentsPath?: string;
3436
}
3537

3638
/**

workers-registry/acp-worker/src/registry-launcher/index.ts

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@
3737

3838
import { loadConfig } from './config/config.js';
3939
import { loadApiKeys } from './config/api-keys.js';
40-
import { RegistryFetchError, RegistryIndex, RegistryParseError } from './registry/index.js';
40+
import { RegistryFetchError, RegistryIndex, RegistryParseError, CustomAgentsLoadError, loadCustomAgents } from './registry/index.js';
4141
import { NDJSONHandler } from './stream/ndjson-handler.js';
4242
import { AgentRuntimeManager } from './runtime/manager.js';
4343
import { MessageRouter } from './router/message-router.js';
44-
import { logError, logExit, logInfo } from './log.js';
44+
import { logError, logExit, logInfo, logWarn } from './log.js';
4545

4646
/**
4747
* Exit codes for the Registry Launcher.
@@ -59,22 +59,53 @@ const ExitCodes = {
5959
let isShuttingDown = false;
6060

6161
/**
62-
* Parse command-line arguments to get the config file path.
62+
* Parsed command-line arguments.
63+
*/
64+
interface ParsedArgs {
65+
/** Path to the config file (positional argument) */
66+
configPath?: string;
67+
/** Path to the custom agents JSON file (--custom-agents <path>) */
68+
customAgentsPath?: string;
69+
}
70+
71+
/**
72+
* Parse command-line arguments.
6373
*
64-
* Usage: node index.js [config-path]
74+
* Usage: node index.js [config-path] [--custom-agents <path>]
6575
*
66-
* @returns The config file path or undefined if not provided
76+
* @returns Parsed arguments
6777
*/
68-
function parseArgs(): string | undefined {
78+
function parseArgs(): ParsedArgs {
6979
// argv[0] is node, argv[1] is the script path
70-
// argv[2] is the first argument (config path)
7180
const args = process.argv.slice(2);
81+
const result: ParsedArgs = {};
82+
83+
let i = 0;
84+
while (i < args.length) {
85+
const arg = args[i];
86+
87+
if (arg === '--custom-agents') {
88+
const nextArg = args[i + 1];
89+
if (nextArg && !nextArg.startsWith('-')) {
90+
result.customAgentsPath = nextArg;
91+
i += 2;
92+
continue;
93+
}
94+
// --custom-agents without value: log warning and skip
95+
logWarn('--custom-agents requires a file path argument, ignoring');
96+
i += 1;
97+
continue;
98+
}
99+
100+
// First non-flag argument is the config path
101+
if (!arg.startsWith('-') && !result.configPath) {
102+
result.configPath = arg;
103+
}
72104

73-
if (args.length > 0 && args[0] && !args[0].startsWith('-')) {
74-
return args[0];
105+
i += 1;
75106
}
76107

77-
return undefined;
108+
return result;
78109
}
79110

80111
/**
@@ -240,13 +271,19 @@ async function main(): Promise<void> {
240271
logInfo('Registry Launcher starting');
241272

242273
// Parse command-line arguments
243-
const configPath = parseArgs();
244-
if (configPath) {
245-
logInfo(`Loading configuration from: ${configPath}`);
274+
const parsedArgs = parseArgs();
275+
if (parsedArgs.configPath) {
276+
logInfo(`Loading configuration from: ${parsedArgs.configPath}`);
246277
}
247278

248279
// Load configuration
249-
const config = loadConfig(configPath);
280+
const config = loadConfig(parsedArgs.configPath);
281+
282+
// CLI --custom-agents takes precedence over config file and env
283+
if (parsedArgs.customAgentsPath) {
284+
config.customAgentsPath = parsedArgs.customAgentsPath;
285+
}
286+
250287
logInfo(`Configuration loaded: registryUrl=${config.registryUrl}, apiKeysPath=${config.apiKeysPath}, shutdownTimeoutSec=${config.shutdownTimeoutSec}`);
251288

252289
// Load API keys
@@ -271,6 +308,26 @@ async function main(): Promise<void> {
271308
process.exit(ExitCodes.FATAL_ERROR);
272309
}
273310

311+
// Load and merge custom agents if --custom-agents was provided
312+
if (config.customAgentsPath) {
313+
try {
314+
logInfo(`Loading custom agents from: ${config.customAgentsPath}`);
315+
const customAgents = loadCustomAgents(config.customAgentsPath);
316+
registry.mergeCustomAgents(customAgents);
317+
} catch (error) {
318+
if (error instanceof CustomAgentsLoadError) {
319+
logError(`Failed to load custom agents: ${error.message}`);
320+
process.exit(ExitCodes.FATAL_ERROR);
321+
}
322+
if (error instanceof RegistryParseError) {
323+
logError(`Invalid custom agents file: ${error.message}`);
324+
process.exit(ExitCodes.FATAL_ERROR);
325+
}
326+
logError(`Unexpected error loading custom agents: ${(error as Error).message}`);
327+
process.exit(ExitCodes.FATAL_ERROR);
328+
}
329+
}
330+
274331
// Create runtime manager
275332
const runtimeManager = new AgentRuntimeManager();
276333

workers-registry/acp-worker/src/registry-launcher/registry/index.property.test.ts

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -127,33 +127,6 @@ const distributionArb: fc.Arbitrary<Distribution> = fc.oneof(
127127
uvxDistributionArb.map((uvx) => ({ uvx })),
128128
);
129129

130-
/**
131-
* Arbitrary for generating valid environment variable keys.
132-
* Excludes __proto__, constructor, and other prototype-related keys that have
133-
* special behavior in JavaScript and are not valid environment variable names.
134-
*/
135-
const envKeyArb = fc
136-
.string({ minLength: 1, maxLength: 20 })
137-
.filter((s) => /^[A-Z_][A-Z0-9_]*$/i.test(s))
138-
.filter((s) => !['__proto__', 'constructor', 'prototype', 'hasOwnProperty', 'toString'].includes(s));
139-
140-
/**
141-
* Arbitrary for generating valid environment variable records.
142-
*/
143-
const envRecordArb: fc.Arbitrary<Record<string, string>> = fc.dictionary(
144-
envKeyArb,
145-
fc.string({ maxLength: 100 }),
146-
{ minKeys: 0, maxKeys: 5 },
147-
);
148-
149-
/**
150-
* Arbitrary for generating valid command-line arguments.
151-
*/
152-
const argsArrayArb: fc.Arbitrary<string[]> = fc.array(
153-
fc.string({ maxLength: 50 }),
154-
{ minLength: 0, maxLength: 5 },
155-
);
156-
157130
/**
158131
* Arbitrary for generating valid RegistryAgent objects.
159132
*/

0 commit comments

Comments
 (0)