Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/core/dev/inspector/invocations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ async function* transformA2aSse(
for await (const data of sseData(stream)) {
try {
const event = JSON.parse(data) as Record<string, unknown>;
const { text, kind } = extractSseEventText(event, streamedFromStatus);
const { text, kind } = extractA2aEventText(event, streamedFromStatus);
if (text) {
if (kind === "status-update") streamedFromStatus = true;
yield sseEvent(text);
Expand All @@ -193,7 +193,7 @@ async function* transformA2aSse(
}

// When streamedFromStatus is set, artifact-update text is skipped because status-update already streamed it.
function extractSseEventText(
function extractA2aEventText(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im confused why did we change this method name? isnt it still extracting from SSE?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I see this comment in the previous PR #2085 (comment)

@tejaskash tejaskash Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, it only handles A2A event kinds, so the generic SSE name was misleading.

event: Record<string, unknown>,
streamedFromStatus: boolean,
): { text: string | null; kind: string | undefined } {
Expand Down
10 changes: 5 additions & 5 deletions src/core/dev/otel/collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe("startOtelCollector", () => {
const response = await post("/v1/traces", protobufTracePayload());
expect(response.status).toBe(200);

const traces = await collector.store.list();
const traces = await collector.traces.list();
expect(traces).toHaveLength(1);
expect(traces[0]!.traceId).toBe(TRACE_ID_HEX);
});
Expand All @@ -100,7 +100,7 @@ describe("startOtelCollector", () => {
const response = await post("/v1/logs", protobufLogsPayload());
expect(response.status).toBe(200);

const detail = await collector.store.get(TRACE_ID_HEX);
const detail = await collector.traces.get(TRACE_ID_HEX);
expect(detail?.resourceLogs).toBeDefined();
});

Expand Down Expand Up @@ -129,13 +129,13 @@ describe("startOtelCollector", () => {
});
const response = await post("/v1/traces", body, "application/json");
expect(response.status).toBe(200);
expect((await collector.store.list()).map((trace) => trace.traceId)).toEqual([TRACE_ID_HEX]);
expect((await collector.traces.list()).map((trace) => trace.traceId)).toEqual([TRACE_ID_HEX]);
});

test("rejects malformed payloads with 400", async () => {
expect((await post("/v1/traces", "not json", "application/json")).status).toBe(400);
expect((await post("/v1/traces", Buffer.from([0xff, 0xff, 0xff]))).status).toBe(400);
expect(await collector.store.list()).toEqual([]);
expect(await collector.traces.list()).toEqual([]);
});

test.each(["null", "[]", "42", '{"resourceSpans":5}'])(
Expand All @@ -154,7 +154,7 @@ describe("startOtelCollector", () => {
});
expect(response.status).toBe(400);
expect(errors).toEqual([]);
expect(await strict.store.list()).toEqual([]);
expect(await strict.traces.list()).toEqual([]);
} finally {
await strict.close();
}
Expand Down
9 changes: 7 additions & 2 deletions src/core/dev/otel/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface OtelCollector {
/** The port the OTLP/HTTP receiver listens on. */
port: number;
/** Reads the traces this collector persists. */
store: TraceStore;
traces: TraceStore;
/** Environment variables that point an agent's OTEL SDK at this collector. */
envVars: Record<string, string>;
/** Stops the receiver. Also invoked by the start signal, if one was given. */
Expand Down Expand Up @@ -68,7 +68,12 @@ export async function startOtelCollector(
signal: options.signal,
});

return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close };
return {
port: server.port,
traces: store,
envVars: otelEnvVars(server.port),
close: server.close,
};
}

async function route(
Expand Down
14 changes: 13 additions & 1 deletion src/core/dev/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,19 @@ export async function resolveDevPort(
checkPort: PortChecker,
signal: AbortSignal,
): Promise<DevPort> {
const defaultPort = DEV_PORTS[protocol ?? "HTTP"];
return findFreePort(DEV_PORTS[protocol ?? "HTTP"], explicitPort, checkPort, signal);
}

/**
* Resolve a free port from `defaultPort`. An explicit port must be free or the
* call fails; otherwise the next free port from the default up is taken.
*/
export async function findFreePort(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question, certain protocols reserve a port right? if that port is busy, are we still finding a new port for them?

@tejaskash tejaskash Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we scan for the next free port unless you pass an explicit --port.

defaultPort: number,
explicitPort: number | undefined,
checkPort: PortChecker,
signal: AbortSignal,
): Promise<DevPort> {
const requestedPort = explicitPort ?? defaultPort;

if (await checkPort(requestedPort, signal)) {
Expand Down
18 changes: 18 additions & 0 deletions src/core/dev/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function serverRunner(events: DevEvent[] = []) {
run: async function* (input) {
inputs.push(input);
yield* events;
if (input.signal.aborted) return;
await new Promise<void>((resolve) =>
input.signal.addEventListener("abort", () => resolve(), { once: true }),
);
Expand Down Expand Up @@ -360,5 +361,22 @@ describe("DevSupervisor", () => {
await consuming;

expect(codeZip.inputs[0]!.signal.aborted).toBe(true);
// events() waits for the child's pump before ending, so the agent's final
// "stopped" event is drained rather than lost when the collector closes.
expect(events).toContainEqual({
agentName: "orders",
event: { type: "status", message: "Agent 'orders' stopped." },
});
});

test("an edit to a running agent applies on its next start, not live", async () => {
const { supervisor, controller } = harness();
await supervisor.start("orders");

supervisor.setRuntimes([{ ...runtime("orders"), protocol: "A2A" } as ProjectRuntime]);
// The live process keeps its protocol, so the Inspector never proxies it with
// metadata that no longer matches the running child.
expect(supervisor.running("orders")).toEqual({ port: 9100, protocol: "HTTP" });
controller.abort();
});
});
42 changes: 32 additions & 10 deletions src/core/dev/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ type AgentEntry = {
port?: number;
error?: string;
starting?: Promise<{ name: string; port: number }>;
/** The running child's pump, so shutdown can await its final spans. */
running?: Promise<void>;
/** A reloaded definition held for a live agent, applied on its next start. */
pendingRuntime?: ProjectRuntime;
};

/**
Expand Down Expand Up @@ -79,8 +83,15 @@ export class DevSupervisor {
const names = new Set(runtimes.map((runtime) => runtime.name));
for (const runtime of runtimes) {
const existing = this.agents.get(runtime.name);
if (existing) existing.runtime = runtime;
else this.agents.set(runtime.name, { runtime, phase: "idle" });
if (!existing) {
this.agents.set(runtime.name, { runtime, phase: "idle" });
} else if (existing.phase === "running" || existing.phase === "starting") {
// A live process keeps its current definition; the edit applies on next
// start, so the Inspector never proxies a running agent with stale metadata.
existing.pendingRuntime = runtime;
} else {
existing.runtime = runtime;
}
}
for (const [name, entry] of this.agents) {
if (!names.has(name) && entry.phase !== "running" && entry.phase !== "starting") {
Expand Down Expand Up @@ -128,6 +139,10 @@ export class DevSupervisor {
}
if (entry.starting) return entry.starting;

if (entry.pendingRuntime) {
entry.runtime = entry.pendingRuntime;
entry.pendingRuntime = undefined;
}
entry.starting = this.launch(entry).finally(() => {
entry.starting = undefined;
});
Expand All @@ -141,7 +156,14 @@ export class DevSupervisor {
public async *events(): AsyncGenerator<SupervisedEvent, void> {
while (true) {
for (const event of this.queue.splice(0)) yield event;
if (this.config.signal.aborted) return;
if (this.config.signal.aborted) {
// Let every live child finish shutting down so its final spans reach the
// collector before the caller closes it, then drain what they emitted.
const running = [...this.agents.values()].map((entry) => entry.running).filter(Boolean);
await Promise.allSettled(running);
for (const event of this.queue.splice(0)) yield event;
return;
}
await new Promise<void>((resolve) => {
this.wake = resolve;
// A push during the yields above ran while wake was undefined, so its
Expand Down Expand Up @@ -180,14 +202,14 @@ export class DevSupervisor {
const readiness = this.waitReady(port, controller.signal, () => activity.at).then(() => {
ready = true;
});
const earlyExit = this.pump(entry, runner, { port, env, signal: controller.signal }, () => {
const pump = this.pump(entry, runner, { port, env, signal: controller.signal }, () => {
activity.at = Date.now();
})
.finally(unchain)
.then(() => {
if (!ready)
throw new Error(entry.error ?? `Agent '${name}' exited before it became ready.`);
});
});
entry.running = pump;
const earlyExit = pump.finally(unchain).then(() => {
if (!ready)
throw new Error(entry.error ?? `Agent '${name}' exited before it became ready.`);
});
// Both branches outlive the race (the pump runs for the agent's lifetime);
// swallow their late rejections so losing branches never become unhandled.
readiness.catch(() => {});
Expand Down
10 changes: 9 additions & 1 deletion src/core/project/fsUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";

/** The project spec, relative to the project root. */
export const PROJECT_SPEC_RELATIVE_PATH = join("agentcore", "agentcore.json");

/** The project spec's absolute path under `rootPath`. */
export function projectSpecPath(rootPath: string): string {
return join(rootPath, PROJECT_SPEC_RELATIVE_PATH);
}

/** Walks up from directory looking for the agentcore/agentcore.json project marker. */
export function enclosingProjectRoot(directory: string): string | undefined {
for (let current = directory; ; current = dirname(current)) {
if (existsSync(join(current, "agentcore", "agentcore.json"))) {
if (existsSync(join(current, PROJECT_SPEC_RELATIVE_PATH))) {
return current;
}
if (dirname(current) === current) {
Expand Down
6 changes: 3 additions & 3 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { CredentialSchema } from "../../projectSchemas/credential";
import { MemorySchema } from "../../projectSchemas/memory";
import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config";
import { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy";
import { enclosingProjectRoot } from "./fsUtils";
import { enclosingProjectRoot, projectSpecPath } from "./fsUtils";
import {
AgentCoreCLIError,
InputValidationError,
Expand Down Expand Up @@ -92,7 +92,7 @@ export class FsProjectManager implements ProjectManager {
const rootPath = enclosingProjectRoot(input.filePath);
if (!rootPath) return undefined;

const configPath = join(rootPath, "agentcore", "agentcore.json");
const configPath = projectSpecPath(rootPath);
const spec = await this.json.read(configPath, ProjectSpecSchema);
return {
name: spec.name,
Expand Down Expand Up @@ -344,7 +344,7 @@ export class FsProjectManager implements ProjectManager {
}

private getProjectSpecPath(project: Project): string {
return join(project.rootPath, "agentcore", "agentcore.json");
return projectSpecPath(project.rootPath);
}

public async removeResource(project: Project, input: RemoveResourceInput): Promise<Project> {
Expand Down
Loading
Loading