Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
82e3c03
✨ (errors): Add ConfigurationError and GatewayError classes
Chisanan232 May 22, 2026
22ca6b3
✨ (errors): Export ConfigurationError and GatewayError from barrel
Chisanan232 May 22, 2026
7fbb75c
📝 (core): Add gateway-resolver module skeleton + constants
Chisanan232 May 22, 2026
96af6f7
✨ (core): Add probeHealthz to gateway-resolver
Chisanan232 May 22, 2026
edf3e77
✅ (core): Add tests for probeHealthz
Chisanan232 May 22, 2026
d604298
✨ (core): Add waitForHealthz to gateway-resolver
Chisanan232 May 22, 2026
ca50f75
✅ (core): Add tests for waitForHealthz
Chisanan232 May 22, 2026
7733aac
✨ (core): Add loadConfigFile with optional js-yaml import
Chisanan232 May 22, 2026
8335402
⬆ (deps): Add js-yaml + @types/js-yaml as dev dependencies
Chisanan232 May 22, 2026
0341843
✅ (core): Add tests for loadConfigFile
Chisanan232 May 22, 2026
e9c1c79
✨ (core): Add findAasmOnPath + spawnAasm seams + __testing handle
Chisanan232 May 22, 2026
20b72da
✨ (core): Add autoStartGateway
Chisanan232 May 22, 2026
a74aa78
✅ (core): Add tests for autoStartGateway
Chisanan232 May 22, 2026
0853ef8
✨ (core): Add resolveGatewayUrl public resolver
Chisanan232 May 22, 2026
99065ee
✅ (core): Add precedence tests for resolveGatewayUrl
Chisanan232 May 22, 2026
f9dce45
✨ (core): Add resolveApiKey public resolver
Chisanan232 May 22, 2026
345d1aa
✅ (core): Add precedence tests for resolveApiKey
Chisanan232 May 22, 2026
1de2607
♻️ (core): Relax AssemblyConfig + wire resolver into initAssembly
Chisanan232 May 22, 2026
6b9a968
✅ (test): Add zero-arg initAssembly resolves local default
Chisanan232 May 22, 2026
2deb416
✅ (test): Add regression for explicit gatewayUrl/apiKey callers
Chisanan232 May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@napi-rs/cli": "3.6.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.8.0",
"@vitest/coverage-v8": "^2.1.8",
"blocked-at": "^1.2.0",
"eslint": "^10.4.0",
"grpc-tools": "^1.13.1",
"js-yaml": "^4.1.1",
"langchain": "^1.4.0",
"prettier": "^3.5.3",
"ts-proto": "^2.11.8",
Expand All @@ -74,11 +76,11 @@
],
"optionalDependencies": {
"@agent-assembly/darwin-arm64": "0.0.0",
"@agent-assembly/win32-x64-msvc": "0.0.0",
"@agent-assembly/runtime-linux-x64": "0.0.0",
"@agent-assembly/runtime-linux-arm64": "0.0.0",
"@agent-assembly/runtime-darwin-arm64": "0.0.0",
"@agent-assembly/runtime-darwin-x64": "0.0.0",
"@agent-assembly/runtime-darwin-arm64": "0.0.0"
"@agent-assembly/runtime-linux-arm64": "0.0.0",
"@agent-assembly/runtime-linux-x64": "0.0.0",
"@agent-assembly/win32-x64-msvc": "0.0.0"
},
"files": [
"dist/",
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

244 changes: 244 additions & 0 deletions src/core/gateway-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve as resolvePath } from "node:path";

import { ConfigurationError, GatewayError } from "../errors/index.js";

/**
* Resolve the gateway URL and API key for ``initAssembly``.
*
* Implements the zero-config developer-experience contract from Epic 17 (S-G):
* ``initAssembly({})`` with no fields and no environment variables should
* discover a local gateway at ``http://localhost:7391`` — probing it, and
* auto-starting ``aasm start --mode local --foreground`` when not running.
*
* Resolution precedence (highest first):
*
* 1. Explicit field on the AssemblyConfig
* 2. Environment variable (AAASM_GATEWAY_URL / AAASM_API_KEY)
* 3. Config file (~/.aasm/config.yaml, optional js-yaml soft dep)
* 4. Local default: probe http://localhost:7391, auto-start if absent
*/

export const DEFAULT_GATEWAY_URL = "http://localhost:7391";
export const DEFAULT_HEALTHZ_PATH = "/healthz";
export const DEFAULT_PROBE_TIMEOUT_MS = 500;
export const DEFAULT_AUTO_START_TIMEOUT_MS = 5000;
export const DEFAULT_CONFIG_FILE_PATH = "~/.aasm/config.yaml";

export const ENV_GATEWAY_URL = "AAASM_GATEWAY_URL";
export const ENV_API_KEY = "AAASM_API_KEY";

export const AASM_AUTO_START_ARGV = ["start", "--mode", "local", "--foreground"] as const;

/**
* Return true if a gateway responds with a 2xx status at ``{baseUrl}/healthz``.
*
* Uses the global ``fetch`` (Node 18+) with an AbortController-driven
* timeout. Any network / timeout / parse error is swallowed and reported
* as ``false`` — the resolver treats unreachable as "absent" rather than
* fatal.
*/
export async function probeHealthz(
baseUrl: string,
timeoutMs: number = DEFAULT_PROBE_TIMEOUT_MS
): Promise<boolean> {
const url = baseUrl.replace(/\/+$/, "") + DEFAULT_HEALTHZ_PATH;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return response.status >= 200 && response.status < 300;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}

/**
* Poll the gateway healthz endpoint until success or timeout.
*
* Resolves ``true`` as soon as ``probeHealthz`` succeeds, ``false`` if
* the gateway has not become ready within ``timeoutMs``. The poll
* interval is short (default 100ms) so the auto-start path feels
* instant when the local CP comes up quickly.
*/
export async function waitForHealthz(
baseUrl: string,
timeoutMs: number = DEFAULT_AUTO_START_TIMEOUT_MS,
pollIntervalMs: number = 100
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await probeHealthz(baseUrl)) {
return true;
}
await new Promise<void>((resolve) => setTimeout(resolve, pollIntervalMs));
}
return probeHealthz(baseUrl);
}

function expandHome(p: string): string {
return p.startsWith("~") ? resolvePath(homedir(), p.slice(p.startsWith("~/") ? 2 : 1)) : p;

Check warning on line 84 in src/core/gateway-resolver.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=AI-agent-assembly_node-sdk&issues=AZ5RANZaOZg2hyVeDsG2&open=AZ5RANZaOZg2hyVeDsG2&pullRequest=47
}

/**
* Load ``~/.aasm/config.yaml`` if present.
*
* Returns an empty record when the file is missing, when ``js-yaml`` is
* not installed (it is a soft dependency for SDK consumers), or when
* the file's contents are not an object. This keeps the resolver chain
* purely advisory at step 3 — never throws.
*/
export async function loadConfigFile(
configPath: string = DEFAULT_CONFIG_FILE_PATH
): Promise<Record<string, unknown>> {
// Indirect specifier defeats static module resolution so missing js-yaml
// surfaces at runtime (caught below) rather than as a TS compile error.
const yamlSpec = "js-yaml";
let yamlMod: { load: (input: string) => unknown };
try {
yamlMod = (await import(yamlSpec)) as { load: (input: string) => unknown };
} catch {
return {};
}

const expanded = expandHome(configPath);
if (!existsSync(expanded)) {
return {};
}

try {
const parsed = yamlMod.load(readFileSync(expanded, "utf8"));
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}

function defaultFindAasmOnPath(): string | null {
const PATH = process.env.PATH ?? "";
const sep = process.platform === "win32" ? ";" : ":";
const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
for (const dir of PATH.split(sep)) {
if (!dir) continue;
for (const ext of exts) {
const candidate = join(dir, `aasm${ext}`);
if (existsSync(candidate)) return candidate;
}
}
return null;
}

function defaultSpawnAasm(aasmPath: string): void {
const child = spawn(aasmPath, [...AASM_AUTO_START_ARGV], {
detached: true,
stdio: "ignore",
});
child.unref();
}

/**
* Mutable seams used by ``autoStartGateway`` — exposed via ``__testing``
* so tests can stub the PATH lookup and subprocess spawn without using
* ESM module mocking. Production callers should treat this as private.
*/
const _seams = {
findAasmOnPath: defaultFindAasmOnPath,
spawnAasm: defaultSpawnAasm,
probeHealthz: probeHealthz,
loadConfigFile: loadConfigFile,
autoStartGateway: autoStartGateway,
};

export const __testing = { _seams };

/**
* Spawn ``aasm start --mode local --foreground`` and wait until ``/healthz``
* responds.
*
* Throws ``ConfigurationError`` when the ``aasm`` binary is missing from
* PATH — the SDK cannot meaningfully auto-start without it. Throws
* ``GatewayError`` when the spawned gateway does not become ready within
* ``timeoutMs``. The subprocess is launched detached + stdio:"ignore" so
* it survives the parent Node process — the docker-style daemon hand-off
* described in Epic 17 S-G.
*/
export async function autoStartGateway(
baseUrl: string = DEFAULT_GATEWAY_URL,
timeoutMs: number = DEFAULT_AUTO_START_TIMEOUT_MS
): Promise<void> {
const aasmPath = _seams.findAasmOnPath();
if (aasmPath === null) {
throw new ConfigurationError(
`No gateway found at ${baseUrl} and 'aasm' is not on PATH. ` +
"Install it with: npm install -g @agent-assembly/cli (or pnpm add -g)"
);
}

_seams.spawnAasm(aasmPath);

if (!(await waitForHealthz(baseUrl, timeoutMs))) {
throw new GatewayError(
`Auto-started gateway at ${baseUrl} did not become ready ` +
`within ${(timeoutMs / 1000).toFixed(0)} seconds`
);
}
}

/**
* Resolve the gateway URL using the 4-step precedence chain.
*
* Returns the resolved URL. May spawn a local ``aasm`` subprocess
* (step 4 only). Propagates ``ConfigurationError`` / ``GatewayError``
* from ``autoStartGateway`` when the local default is needed but
* cannot be brought up.
*/
export async function resolveGatewayUrl(explicit?: string): Promise<string> {
if (explicit) return explicit;

const fromEnv = process.env[ENV_GATEWAY_URL];
if (fromEnv) return fromEnv;

const config = await _seams.loadConfigFile();
const agent = config["agent"];
if (agent !== null && typeof agent === "object") {
const url = (agent as Record<string, unknown>)["gateway_url"];
if (typeof url === "string" && url.length > 0) return url;
}

if (await _seams.probeHealthz(DEFAULT_GATEWAY_URL)) {
return DEFAULT_GATEWAY_URL;
}

await _seams.autoStartGateway(DEFAULT_GATEWAY_URL);
return DEFAULT_GATEWAY_URL;
}

/**
* Resolve the API key using the same 4-step precedence as the URL.
*
* Returns the resolved key (possibly empty for local mode, which
* accepts unauthenticated agents). Never rejects — an empty API key
* is the documented "local dev" default per Epic 17.
*/
export async function resolveApiKey(explicit?: string): Promise<string> {
if (explicit) return explicit;

const fromEnv = process.env[ENV_API_KEY];
if (fromEnv) return fromEnv;

const config = await _seams.loadConfigFile();
const agent = config["agent"];
if (agent !== null && typeof agent === "object") {
const apiKey = (agent as Record<string, unknown>)["api_key"];
if (typeof apiKey === "string" && apiKey.length > 0) return apiKey;
}

return "";
}

18 changes: 12 additions & 6 deletions src/core/init-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { hasOpenAIAgentsSDK } from "../hooks/openai-agents-detection.js";
import { patchOpenAIAgents } from "../hooks/openai-agents.js";
import { currentAgentId } from "../lineage/index.js";
import { resolveApiKey, resolveGatewayUrl } from "./gateway-resolver.js";

const requireFromCwd = createRequire(`${process.cwd()}/`);

Expand Down Expand Up @@ -200,16 +201,21 @@
return patchOpenAIAgents({ gatewayClient: client });
}

export async function initAssembly(config: AssemblyConfig): Promise<AssemblyContext> {
export async function initAssembly(config: AssemblyConfig = {}): Promise<AssemblyContext> {

Check failure on line 204 in src/core/init-assembly.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=AI-agent-assembly_node-sdk&issues=AZ5RANchOZg2hyVeDsG3&open=AZ5RANchOZg2hyVeDsG3&pullRequest=47
if (config.delegationReason !== undefined && config.delegationReason.length > 256) {
throw new RangeError("delegationReason must be <= 256 characters");
}
// Auto-populate parentAgentId from the async context store when not explicitly provided.
// This allows child agents spawned inside framework hooks to inherit lineage automatically.
const resolvedParentAgentId = config.parentAgentId ?? currentAgentId();
const resolvedConfig: AssemblyConfig = resolvedParentAgentId
? { ...config, parentAgentId: resolvedParentAgentId }
: config;
const resolvedGatewayUrl = await resolveGatewayUrl(config.gatewayUrl);
const resolvedApiKey = await resolveApiKey(config.apiKey);
const resolvedConfig: AssemblyConfig = {
...config,
gatewayUrl: resolvedGatewayUrl,
apiKey: resolvedApiKey,
...(resolvedParentAgentId ? { parentAgentId: resolvedParentAgentId } : {})
};

const client = createClient(resolvedConfig);
const frameworks = detectFrameworks();
Expand All @@ -222,8 +228,8 @@
let nativeClient: NativeClient | undefined;
if (resolvedConfig.mode !== "sdk-only") {
nativeClient = createNativeClient({
gateway: resolvedConfig.gatewayUrl,
apiKey: resolvedConfig.apiKey,
gateway: resolvedGatewayUrl,
apiKey: resolvedApiKey,
mode: resolvedConfig.mode === "napi-inprocess" ? "napi-inprocess" : "grpc-sidecar",
});
nativeClient.sendEvent(buildRegistrationEvent(resolvedConfig));
Expand Down
13 changes: 13 additions & 0 deletions src/errors/configuration-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Thrown when the SDK cannot resolve the gateway configuration —
* e.g. the local gateway is absent and ``aasm`` is not on ``PATH``.
*
* Mirrors ``agent_assembly.exceptions.ConfigurationError`` in the
* Python SDK so the cross-SDK error contract stays aligned per Epic 17 S-G.
*/
export class ConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigurationError";
}
}
13 changes: 13 additions & 0 deletions src/errors/gateway-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Thrown when the SDK has a gateway URL but cannot talk to it —
* e.g. ``aasm`` was spawned but ``/healthz`` did not become ready
* within the auto-start timeout window.
*
* Mirrors ``agent_assembly.exceptions.GatewayError`` in the Python SDK.
*/
export class GatewayError extends Error {
constructor(message: string) {
super(message);
this.name = "GatewayError";
}
}
2 changes: 2 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { ConfigurationError } from "./configuration-error.js";
export { GatewayError } from "./gateway-error.js";
export { OpTerminatedError } from "./op-terminated-error.js";
export { PolicyViolationError } from "./policy-violation-error.js";
Loading
Loading