Skip to content
Closed
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: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ resources, and `agentcore/cdk/`, the AWS CDK app that deploys them. Deployment t
and region each — live in `agentcore/aws-targets.json`. Project commands find the project by walking
up from the current directory.

`agentcore dev` runs runtimes locally or deploys harnesses and prints invoke guidance.
Use `--mode browser` for Agent Inspector, `--skip-deploy` to reuse deployed harnesses,
and `--agent <name>` to choose an agent in mixed projects.

## Commands

| Command | Purpose |
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

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

56 changes: 29 additions & 27 deletions e2eTest/project/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const HarnessInvokeResponseSchema = z.object({
});

const harnessTags = [TAGS.HARNESS, TAGS.CANARY];
describe("add, deploy, and invoke harnesses", { sequential: true, tags: harnessTags }, () => {
describe("add, dev, deploy, and invoke harnesses", { sequential: true, tags: harnessTags }, () => {
const cli = new CliRunner();
const projectName = `${E2E_PREFIX}${Date.now().toString(36)}`;
let projectDir: string;
Expand Down Expand Up @@ -91,34 +91,36 @@ describe("add, deploy, and invoke harnesses", { sequential: true, tags: harnessT
},
);

test("deploys all harnesses", { timeout: TIMEOUT_MS.PROJECT_DEPLOY }, async () => {
const deployment = parseResult(
DeployResponseSchema,
await cli.run(["deploy", "--yes", "--json"], projectDir),
);
expect(deployment.message).toContain("Deployed project");
});

test.each(HARNESS_TEST_CASES)(
"$name can be invoked after deployed",
{ concurrent: true, timeout: TIMEOUT_MS.PROJECT_INVOKE },
async (harness) => {
const response = parseResult(
HarnessInvokeResponseSchema,
await cli.run(
["invoke", "--harness", harness.name, "--json", ...harness.invokeFlags],
projectDir,
),
describe.each(["dev", "deploy"])("%s", { sequential: true }, (command) => {
test("deploys all harnesses", { timeout: TIMEOUT_MS.PROJECT_DEPLOY }, async () => {
const deployment = parseResult(
DeployResponseSchema,
await cli.run([command, "--yes", "--json"], projectDir),
);
const responseText = response.transcript
.filter((item) => item.kind === "text")
.flatMap((item) => item.text ?? [])
.join("");
expect(deployment.message).toContain("Deployed project");
});

expect(responseText.trim()).not.toBe("");
if (harness.expectedText) expect(responseText).toContain(harness.expectedText);
},
);
test.each(HARNESS_TEST_CASES)(
"$name can be invoked after deployed",
{ concurrent: true, timeout: TIMEOUT_MS.PROJECT_INVOKE },
async (harness) => {
const response = parseResult(
HarnessInvokeResponseSchema,
await cli.run(
["invoke", "--harness", harness.name, "--json", ...harness.invokeFlags],
projectDir,
),
);
const responseText = response.transcript
.filter((item) => item.kind === "text")
.flatMap((item) => item.text ?? [])
.join("");

expect(responseText.trim()).not.toBe("");
if (harness.expectedText) expect(responseText).toContain(harness.expectedText);
},
);
});

test(
"removes all harnesses and deploys the empty project",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"bedrock-agentcore": "^0.4.3",
"cli-truncate": "^6.1.1",
"commander": "^15.0.0",
"handlebars": "^4.7.9",
"handlebars": "^4.7.10",
"ink": "^7.1.0",
"ink-scroll-view": "^0.3.7",
"lodash": "^4.18.1",
Expand Down
111 changes: 111 additions & 0 deletions src/core/dev/inspector/invocations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// Every upstream fetch carries the client abort signal, so a browser disconnect tears down the agent request.
import { randomUUID } from "node:crypto";
import type {
InvokeHarnessRequest,
InvokeHarnessStreamOutput,
} from "@aws-sdk/client-bedrock-agentcore";
import type { HttpRequest, HttpResponse } from "../../../io/httpServer";
import {
apiError,
Expand All @@ -18,6 +22,9 @@ export async function handleInvocations(
request: HttpRequest,
): Promise<HttpResponse> {
const parsed = parseJsonBody(request.body);
if (parsed && "harnessName" in parsed) {
return invokeHarness({ deps, body: parsed, signal: request.signal });
}
const agentName = asString(parsed?.agentName);
// Request header, agent body, and echoed x-session-id must agree, so one session id is computed once.
const sessionId = asString(parsed?.sessionId) ?? randomUUID();
Expand Down Expand Up @@ -46,6 +53,110 @@ export async function handleInvocations(
});
}

type HarnessOverrides = Partial<Omit<InvokeHarnessRequest, "systemPrompt">> & {
systemPrompt?: string;
};

async function invokeHarness({
deps,
body,
signal,
}: {
deps: InspectorDeps;
body: Record<string, unknown>;
signal: AbortSignal;
}): Promise<HttpResponse> {
const name = asString(body.harnessName);
if (!name?.trim()) return apiError(400, "harnessName is required");
const prompt = asString(body.prompt);
if (!prompt?.trim()) return apiError(400, "prompt is required");
if (!deps.project?.spec.harnesses.some((harness) => harness.name === name)) {
return apiError(404, `Harness "${name}" not found`);
}
if (!deps.invokeHarness) return apiError(409, "Harness invocation is not available");

const sessionId = asString(body.sessionId) ?? randomUUID();
const overrides = body.harnessOverrides as HarnessOverrides | undefined;
const systemPrompt = asString(overrides?.systemPrompt);
try {
const response = await deps.invokeHarness(
name,
{
...overrides,
qualifier: overrides?.qualifier ?? "DEFAULT",
runtimeSessionId: sessionId,
runtimeUserId: asString(body.userId),
messages: [{ role: "user", content: [{ text: prompt }] }],
systemPrompt: systemPrompt ? [{ text: systemPrompt }] : undefined,
},
signal,
);
return sse(transformHarnessSse(response.stream ?? []), sessionId);
} catch (error) {
return apiError(502, `Harness invocation failed: ${errorMessage(error)}`);
}
}

async function* transformHarnessSse(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

is there logic we can re-use from the harness invoke path?

stream: AsyncIterable<InvokeHarnessStreamOutput> | Iterable<InvokeHarnessStreamOutput>,
): AsyncGenerator<Uint8Array, void> {
try {
for await (const event of stream) {
const payload = harnessStreamEvent(event);
if (payload) yield sseEvent(payload);
}
} catch (error) {
yield sseEvent({ type: "error", errorType: "invocationError", message: errorMessage(error) });
}
}

function harnessStreamEvent(event: InvokeHarnessStreamOutput): unknown {
if (event.messageStart) return { type: "messageStart", ...event.messageStart };
if (event.contentBlockStart) {
const { start, contentBlockIndex } = event.contentBlockStart;
const type = start?.toolUse ? "toolUse" : start?.toolResult ? "toolResult" : undefined;
if (type) {
return {
type: "contentBlockStart",
contentBlockIndex,
start: { type, ...start },
};
}
}
if (event.contentBlockDelta) {
const { delta, contentBlockIndex } = event.contentBlockDelta;
let payload: unknown;
if (delta?.text !== undefined) payload = { type: "text", text: delta.text };
else if (delta?.toolUse) payload = { type: "toolUse", ...delta.toolUse };
else if (delta?.toolResult) {
payload = {
type: "toolResult",
results: delta.toolResult.map((chunk) =>
chunk.json !== undefined ? { text: JSON.stringify(chunk.json) } : chunk,
),
};
} else if (delta?.reasoningContent) {
payload = { type: "reasoningContent", ...delta.reasoningContent };
}
if (payload) return { type: "contentBlockDelta", contentBlockIndex, delta: payload };
}
if (event.contentBlockStop) return { type: "contentBlockStop", ...event.contentBlockStop };
if (event.messageStop) return { type: "messageStop", ...event.messageStop };
if (event.metadata) return { type: "metadata", ...event.metadata };
for (const errorType of [
"validationException",
"internalServerException",
"runtimeClientError",
] as const) {
const error = event[errorType];
if (error) return { type: "error", errorType, message: error.message ?? String(error) };
}
if (event.$unknown) {
const [errorType] = event.$unknown;
return { type: "error", errorType, message: `Unknown harness stream event: ${errorType}` };
}
}

async function forwardInvocation(
port: number,
body: Buffer | string,
Expand Down
9 changes: 9 additions & 0 deletions src/core/dev/inspector/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
* implementations (DevSupervisor, the trace store, bundled assets) at the edge,
* and tests inject fakes.
*/
import type {
InvokeHarnessRequest,
InvokeHarnessResponse,
} from "@aws-sdk/client-bedrock-agentcore";
import type { Project } from "../../../handlers/project/types";

/** One managed agent's state, as reported by GET /api/status. */
Expand Down Expand Up @@ -54,4 +58,9 @@ export interface InspectorDeps {
project?: Project;
/** Agent name to pre-select in the UI. */
selectedAgent?: string;
invokeHarness?: (
name: string,
input: Omit<InvokeHarnessRequest, "harnessArn">,
signal: AbortSignal,
) => Promise<InvokeHarnessResponse>;
}
Loading