From d15ce2e4363897496b7db5b532d08adb9132bc9e Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 5 Mar 2026 11:46:50 -0500 Subject: [PATCH 01/49] feat: add sync workflow --- .github/workflows/sync-from-public.yml | 136 +++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/sync-from-public.yml diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml new file mode 100644 index 000000000..07d51875b --- /dev/null +++ b/.github/workflows/sync-from-public.yml @@ -0,0 +1,136 @@ +name: Sync from Public Repo + +on: + schedule: + - cron: '0 */6 * * *' # Every 6 hours + workflow_dispatch: # Manual trigger via Actions tab + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch public main + run: | + git remote add public https://github.com/aws/agentcore-cli.git + git fetch public main + + - name: Sync all private branches with public/main + run: | + synced=() + conflicts=() + skipped=() + + for branch in $(git branch -r --list 'origin/*' | sed 's|^ *origin/||'); do + # Skip HEAD pointer and previous sync-conflict branches + if [ "$branch" = "HEAD" ] || [[ "$branch" == *"->"* ]] || [[ "$branch" == sync-conflict-* ]]; then + continue + fi + + echo "=== Processing branch: $branch ===" + + git checkout $branch + git reset --hard origin/$branch + + # Check if public/main is already merged into this branch + if git merge-base --is-ancestor public/main HEAD; then + echo "✅ $branch is already up to date with public/main" + synced+=("$branch (already up to date)") + continue + fi + + # Try to merge public/main + if git merge public/main -m "chore: sync $branch with public/main"; then + git push origin $branch + synced+=("$branch") + echo "✅ $branch synced successfully" + else + echo "⚠️ Conflict detected in $branch" + + # Capture conflicted files before aborting + conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") + git merge --abort + + # Check if a sync PR already exists for this branch + existing_pr=$(gh pr list --base "$branch" --search "Sync public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$existing_pr" ]; then + echo "ℹ️ PR #$existing_pr already exists for $branch, skipping" + skipped+=("$branch (existing PR #$existing_pr)") + continue + fi + + conflict_branch="sync-conflict-$branch-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$conflict_branch" + + git merge public/main --no-commit --no-ff || true + git add -A + git commit -m "chore: sync $branch with public/main (conflicts present) + + This automated sync detected merge conflicts that require manual resolution. + + Source: public/main (https://github.com/aws/agentcore-cli) + Target: $branch + + Please resolve conflicts and merge this PR." || true + + git push origin "$conflict_branch" + + gh pr create \ + --title "🔀 [Sync Conflict] Merge public/main → $branch" \ + --body "## Automated Sync Conflict + + This PR was automatically created because merging \`public/main\` into \`$branch\` encountered conflicts. + + **Source:** \`main\` from [aws/agentcore-cli](https://github.com/aws/agentcore-cli) + **Target:** \`$branch\` + + ### Action Required + 1. \`git fetch origin && git checkout $conflict_branch\` + 2. Resolve merge conflicts + 3. \`git add . && git commit\` + 4. \`git push origin $conflict_branch\` + 5. Merge this PR + + ### Files with Conflicts + \`\`\` + $conflicted_files + \`\`\`" \ + --base "$branch" \ + --head "$conflict_branch" || echo "⚠️ Failed to create PR for $branch" + + conflicts+=("$branch") + git checkout "$branch" + fi + done + + # Summary + echo "" + echo "=== Sync Summary ===" + echo "✅ Synced: ${#synced[@]} branches" + echo "⚠️ Conflicts: ${#conflicts[@]} branches" + echo "⏭️ Skipped: ${#skipped[@]} branches" + + if [ ${#conflicts[@]} -gt 0 ]; then + echo "Branches with conflicts (PRs created):" + printf ' - %s\n' "${conflicts[@]}" + fi + if [ ${#skipped[@]} -gt 0 ]; then + echo "Branches skipped (existing PRs):" + printf ' - %s\n' "${skipped[@]}" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7d35986ed02c3efff983a0192bdff8a9a298d9d9 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 5 Mar 2026 13:41:24 -0500 Subject: [PATCH 02/49] fix: formatting --- .github/workflows/sync-from-public.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 07d51875b..63fbdeb5e 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -2,8 +2,8 @@ name: Sync from Public Repo on: schedule: - - cron: '0 */6 * * *' # Every 6 hours - workflow_dispatch: # Manual trigger via Actions tab + - cron: '0 */6 * * *' # Every 6 hours + workflow_dispatch: # Manual trigger via Actions tab permissions: contents: write From 2acb841f7959e03af06863a0ad82b899cfcf977b Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Mon, 9 Mar 2026 11:30:00 -0400 Subject: [PATCH 03/49] fix: only sync to main branch --- .github/workflows/sync-from-public.yml | 109 +++++++++---------------- 1 file changed, 37 insertions(+), 72 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 63fbdeb5e..587219bf8 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -28,75 +28,60 @@ jobs: git remote add public https://github.com/aws/agentcore-cli.git git fetch public main - - name: Sync all private branches with public/main + - name: Sync main with public/main run: | - synced=() - conflicts=() - skipped=() - - for branch in $(git branch -r --list 'origin/*' | sed 's|^ *origin/||'); do - # Skip HEAD pointer and previous sync-conflict branches - if [ "$branch" = "HEAD" ] || [[ "$branch" == *"->"* ]] || [[ "$branch" == sync-conflict-* ]]; then - continue - fi - - echo "=== Processing branch: $branch ===" - - git checkout $branch - git reset --hard origin/$branch + git checkout main + git reset --hard origin/main - # Check if public/main is already merged into this branch - if git merge-base --is-ancestor public/main HEAD; then - echo "✅ $branch is already up to date with public/main" - synced+=("$branch (already up to date)") - continue - fi + # Check if public/main is already merged + if git merge-base --is-ancestor public/main HEAD; then + echo "✅ main is already up to date with public/main" + exit 0 + fi - # Try to merge public/main - if git merge public/main -m "chore: sync $branch with public/main"; then - git push origin $branch - synced+=("$branch") - echo "✅ $branch synced successfully" - else - echo "⚠️ Conflict detected in $branch" + # Try to merge public/main + if git merge public/main -m "chore: sync main with public/main"; then + git push origin main + echo "✅ main synced successfully" + else + echo "⚠️ Conflict detected in main" - # Capture conflicted files before aborting - conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") - git merge --abort + # Capture conflicted files before aborting + conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") + git merge --abort - # Check if a sync PR already exists for this branch - existing_pr=$(gh pr list --base "$branch" --search "Sync public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + # Check if a sync PR already exists + existing_pr=$(gh pr list --base "main" --search "Merge public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") - if [ -n "$existing_pr" ]; then - echo "ℹ️ PR #$existing_pr already exists for $branch, skipping" - skipped+=("$branch (existing PR #$existing_pr)") - continue - fi + if [ -n "$existing_pr" ]; then + echo "ℹ️ PR #$existing_pr already exists, skipping" + exit 0 + fi - conflict_branch="sync-conflict-$branch-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$conflict_branch" + conflict_branch="sync-conflict-main-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$conflict_branch" - git merge public/main --no-commit --no-ff || true - git add -A - git commit -m "chore: sync $branch with public/main (conflicts present) + git merge public/main --no-commit --no-ff || true + git add -A + git commit -m "chore: sync main with public/main (conflicts present) This automated sync detected merge conflicts that require manual resolution. Source: public/main (https://github.com/aws/agentcore-cli) - Target: $branch + Target: main Please resolve conflicts and merge this PR." || true - git push origin "$conflict_branch" + git push origin "$conflict_branch" - gh pr create \ - --title "🔀 [Sync Conflict] Merge public/main → $branch" \ - --body "## Automated Sync Conflict + gh pr create \ + --title "🔀 [Sync Conflict] Merge public/main → main" \ + --body "## Automated Sync Conflict - This PR was automatically created because merging \`public/main\` into \`$branch\` encountered conflicts. + This PR was automatically created because merging \`public/main\` into \`main\` encountered conflicts. **Source:** \`main\` from [aws/agentcore-cli](https://github.com/aws/agentcore-cli) - **Target:** \`$branch\` + **Target:** \`main\` ### Action Required 1. \`git fetch origin && git checkout $conflict_branch\` @@ -109,28 +94,8 @@ jobs: \`\`\` $conflicted_files \`\`\`" \ - --base "$branch" \ - --head "$conflict_branch" || echo "⚠️ Failed to create PR for $branch" - - conflicts+=("$branch") - git checkout "$branch" - fi - done - - # Summary - echo "" - echo "=== Sync Summary ===" - echo "✅ Synced: ${#synced[@]} branches" - echo "⚠️ Conflicts: ${#conflicts[@]} branches" - echo "⏭️ Skipped: ${#skipped[@]} branches" - - if [ ${#conflicts[@]} -gt 0 ]; then - echo "Branches with conflicts (PRs created):" - printf ' - %s\n' "${conflicts[@]}" - fi - if [ ${#skipped[@]} -gt 0 ]; then - echo "Branches skipped (existing PRs):" - printf ' - %s\n' "${skipped[@]}" + --base "main" \ + --head "$conflict_branch" || echo "⚠️ Failed to create PR" fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 05258f31ea7b28644a527fb7a3afb7727c85e3a3 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Mon, 9 Mar 2026 12:01:37 -0400 Subject: [PATCH 04/49] fix: codeql permissions --- .github/workflows/codeql.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1e9b0a4bd..0b3f65d25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,6 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + actions: read security-events: write contents: read From 37a0ff8812fb019eb18a99888544b0fd10acdeab Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:32:51 -0400 Subject: [PATCH 05/49] fix(ci): exclude .github/workflows/ from public repo sync (#54) GITHUB_TOKEN lacks the 'workflows' permission, so pushing workflow file changes from the public repo causes the sync to fail. Use --no-commit --no-ff and restore .github/workflows/ from HEAD before committing, in both the clean merge and conflict paths. --- .github/workflows/sync-from-public.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 587219bf8..94e279079 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -39,8 +39,10 @@ jobs: exit 0 fi - # Try to merge public/main - if git merge public/main -m "chore: sync main with public/main"; then + # Merge but exclude .github/workflows/ (GITHUB_TOKEN lacks workflow permission) + if git merge public/main --no-commit --no-ff; then + git checkout HEAD -- .github/workflows/ 2>/dev/null || true + git commit -m "chore: sync main with public/main" git push origin main echo "✅ main synced successfully" else @@ -62,6 +64,7 @@ jobs: git checkout -b "$conflict_branch" git merge public/main --no-commit --no-ff || true + git checkout HEAD -- .github/workflows/ 2>/dev/null || true git add -A git commit -m "chore: sync main with public/main (conflicts present) From 89fed851f3a89a3307feb1ac3a552994457d9c0a Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:05:12 -0400 Subject: [PATCH 06/49] feat: add shared SigV4 API client and Harness HTTP operations (#84) AgentCoreApiClient: shared SigV4-signed HTTP client with control/data plane endpoint resolution, staging support, structured errors, and raw streaming response support. Harness operations: typed wrappers for CreateHarness (POST), GetHarness (GET), UpdateHarness (PATCH), DeleteHarness (DELETE), ListHarnesses (paginated GET), and InvokeHarness (streaming AsyncGenerator with typed event discriminated union). All paths match the Smithy service model. pollUntilTerminal: generic async polling utility with configurable interval, timeout, and failure detection. --- .../aws/__tests__/agentcore-harness.test.ts | 422 ++++++++++++++ src/cli/aws/__tests__/api-client.test.ts | 185 ++++++ src/cli/aws/__tests__/poll.test.ts | 92 +++ src/cli/aws/agentcore-harness.ts | 537 ++++++++++++++++++ src/cli/aws/api-client.ts | 127 +++++ src/cli/aws/index.ts | 29 + src/cli/aws/poll.ts | 47 ++ 7 files changed, 1439 insertions(+) create mode 100644 src/cli/aws/__tests__/agentcore-harness.test.ts create mode 100644 src/cli/aws/__tests__/api-client.test.ts create mode 100644 src/cli/aws/__tests__/poll.test.ts create mode 100644 src/cli/aws/agentcore-harness.ts create mode 100644 src/cli/aws/api-client.ts create mode 100644 src/cli/aws/poll.ts diff --git a/src/cli/aws/__tests__/agentcore-harness.test.ts b/src/cli/aws/__tests__/agentcore-harness.test.ts new file mode 100644 index 000000000..c6b156d26 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-harness.test.ts @@ -0,0 +1,422 @@ +import { + createHarness, + deleteHarness, + getHarness, + invokeHarness, + listAllHarnesses, + listHarnesses, + updateHarness, +} from '../agentcore-harness.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockRequest, mockRequestRaw } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + mockRequestRaw: vi.fn(), +})); + +vi.mock('../api-client', () => ({ + AgentCoreApiClient: class { + request = mockRequest; + requestRaw = mockRequestRaw; + }, + AgentCoreApiError: class extends Error { + statusCode: number; + requestId: string | undefined; + errorBody: string; + constructor(statusCode: number, errorBody: string, requestId?: string) { + super(`AgentCore API error (${statusCode}): ${errorBody}`); + this.statusCode = statusCode; + this.requestId = requestId; + this.errorBody = errorBody; + } + }, +})); + +describe('Harness control plane operations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createHarness', () => { + it('sends POST /harnesses with correct body', async () => { + const harness = { harnessId: 'h-123', harnessName: 'test', status: 'CREATING' }; + mockRequest.mockResolvedValue({ harness }); + + const result = await createHarness({ + region: 'us-west-2', + harnessName: 'test', + executionRoleArn: 'arn:aws:iam::123:role/TestRole', + model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } }, + systemPrompt: [{ text: 'You are helpful.' }], + tools: [{ type: 'agentcore_browser', name: 'browser' }], + maxIterations: 75, + }); + + expect(result.harness.harnessId).toBe('h-123'); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/harnesses', + body: expect.objectContaining({ + harnessName: 'test', + executionRoleArn: 'arn:aws:iam::123:role/TestRole', + clientToken: expect.any(String), + model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } }, + systemPrompt: [{ text: 'You are helpful.' }], + tools: [{ type: 'agentcore_browser', name: 'browser' }], + maxIterations: 75, + }), + }) + ); + }); + + it('omits optional fields when not provided', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-1' } }); + + await createHarness({ + region: 'us-west-2', + harnessName: 'minimal', + executionRoleArn: 'arn:aws:iam::123:role/R', + }); + + const body = mockRequest.mock.calls[0]![0].body; + expect(body.model).toBeUndefined(); + expect(body.tools).toBeUndefined(); + expect(body.memory).toBeUndefined(); + expect(body.maxIterations).toBeUndefined(); + }); + }); + + describe('getHarness', () => { + it('sends GET /harnesses/{harnessId}', async () => { + const harness = { harnessId: 'h-123', status: 'READY' }; + mockRequest.mockResolvedValue({ harness }); + + const result = await getHarness({ region: 'us-west-2', harnessId: 'h-123' }); + + expect(result.harness.status).toBe('READY'); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/harnesses/h-123', + }) + ); + }); + }); + + describe('updateHarness', () => { + it('sends PATCH /harnesses/{harnessId}', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'UPDATING' } }); + + await updateHarness({ + region: 'us-west-2', + harnessId: 'h-123', + model: { bedrockModelConfig: { modelId: 'new-model' } }, + maxTokens: 4096, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + path: '/harnesses/h-123', + body: expect.objectContaining({ + clientToken: expect.any(String), + model: { bedrockModelConfig: { modelId: 'new-model' } }, + maxTokens: 4096, + }), + }) + ); + }); + + it('passes nullable wrapper fields for memory and environmentArtifact', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123' } }); + + await updateHarness({ + region: 'us-west-2', + harnessId: 'h-123', + memory: { optionalValue: null }, + environmentArtifact: { optionalValue: null }, + }); + + const body = mockRequest.mock.calls[0]![0].body; + expect(body.memory).toEqual({ optionalValue: null }); + expect(body.environmentArtifact).toEqual({ optionalValue: null }); + }); + }); + + describe('deleteHarness', () => { + it('sends DELETE /harnesses/{harnessId} with clientToken query param', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'DELETING' } }); + + await deleteHarness({ region: 'us-west-2', harnessId: 'h-123' }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + path: '/harnesses/h-123', + query: { clientToken: expect.any(String) }, + }) + ); + }); + }); + + describe('listHarnesses', () => { + it('sends GET /harnesses with query params', async () => { + mockRequest.mockResolvedValue({ + harnesses: [{ harnessId: 'h-1', harnessName: 'one' }], + nextToken: undefined, + }); + + const result = await listHarnesses({ region: 'us-west-2', maxResults: 10 }); + + expect(result.harnesses).toHaveLength(1); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/harnesses', + query: { maxResults: '10' }, + }) + ); + }); + }); + + describe('listAllHarnesses', () => { + it('auto-paginates across multiple pages', async () => { + mockRequest + .mockResolvedValueOnce({ + harnesses: [{ harnessId: 'h-1' }], + nextToken: 'tok-1', + }) + .mockResolvedValueOnce({ + harnesses: [{ harnessId: 'h-2' }], + nextToken: undefined, + }); + + const all = await listAllHarnesses('us-west-2'); + + expect(all).toHaveLength(2); + expect(all[0]!.harnessId).toBe('h-1'); + expect(all[1]!.harnessId).toBe('h-2'); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('invokeHarness (streaming)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function makeStreamResponse(events: string[]): Response { + const text = events.join('\n') + '\n'; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + } + + it('yields messageStart events', async () => { + mockRequestRaw.mockResolvedValue(makeStreamResponse([JSON.stringify({ messageStart: { role: 'assistant' } })])); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/h-123', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hello' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ type: 'messageStart', role: 'assistant' }); + }); + + it('yields text deltas', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + JSON.stringify({ contentBlockDelta: { contentBlockIndex: 0, delta: { text: 'Hello' } } }), + JSON.stringify({ contentBlockDelta: { contentBlockIndex: 0, delta: { text: ' world' } } }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ + type: 'contentBlockDelta', + contentBlockIndex: 0, + delta: { type: 'text', text: 'Hello' }, + }); + expect(events[1]).toEqual({ + type: 'contentBlockDelta', + contentBlockIndex: 0, + delta: { type: 'text', text: ' world' }, + }); + }); + + it('yields tool use start events', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + JSON.stringify({ + contentBlockStart: { + contentBlockIndex: 1, + start: { toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' } }, + }, + }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'search' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'contentBlockStart', + contentBlockIndex: 1, + start: { + type: 'toolUse', + toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' }, + }, + }); + }); + + it('yields messageStop with stopReason', async () => { + mockRequestRaw.mockResolvedValue(makeStreamResponse([JSON.stringify({ messageStop: { stopReason: 'end_turn' } })])); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events[0]).toEqual({ type: 'messageStop', stopReason: 'end_turn' }); + }); + + it('yields metadata with token usage', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + JSON.stringify({ + metadata: { + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + metrics: { latencyMs: 1200 }, + }, + }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events[0]).toEqual({ + type: 'metadata', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + metrics: { latencyMs: 1200 }, + }); + }); + + it('yields error events for server exceptions', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([JSON.stringify({ internalServerException: { message: 'Something broke' } })]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events[0]).toEqual({ + type: 'error', + errorType: 'internalServerException', + message: 'Something broke', + }); + }); + + it('passes override options in request body', async () => { + mockRequestRaw.mockResolvedValue(makeStreamResponse([])); + + for await (const _event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + model: { bedrockModelConfig: { modelId: 'override-model' } }, + maxIterations: 20, + skills: [{ path: './skills/research' }], + })) { + // drain + } + + expect(mockRequestRaw).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/harnesses/invoke', + query: { harnessArn: 'arn:harness' }, + headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-1' }, + body: expect.objectContaining({ + model: { bedrockModelConfig: { modelId: 'override-model' } }, + maxIterations: 20, + skills: [{ path: './skills/research' }], + }), + }) + ); + }); + + it('skips unparseable lines gracefully', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + 'not json at all', + '', + ': comment', + JSON.stringify({ messageStop: { stopReason: 'end_turn' } }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe('messageStop'); + }); +}); diff --git a/src/cli/aws/__tests__/api-client.test.ts b/src/cli/aws/__tests__/api-client.test.ts new file mode 100644 index 000000000..5ebc1ebd3 --- /dev/null +++ b/src/cli/aws/__tests__/api-client.test.ts @@ -0,0 +1,185 @@ +import { AgentCoreApiClient, AgentCoreApiError } from '../api-client.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSign } = vi.hoisted(() => ({ + mockSign: vi.fn(), +})); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + sign = mockSign; + }, +})); + +vi.mock('@smithy/protocol-http', () => ({ + HttpRequest: class { + constructor(public opts: unknown) {} + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn().mockReturnValue({}), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('AgentCoreApiClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.AGENTCORE_STAGE; + mockSign.mockResolvedValue({ headers: { host: 'example.com', 'content-type': 'application/json' } }); + }); + + describe('endpoint resolution', () => { + it('uses control plane prod endpoint by default', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('bedrock-agentcore-control.us-west-2.amazonaws.com'), + expect.anything() + ); + }); + + it('uses data plane prod endpoint', async () => { + const client = new AgentCoreApiClient({ region: 'us-east-1', plane: 'data' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('bedrock-agentcore.us-east-1.amazonaws.com'), + expect.anything() + ); + }); + + it('uses beta control plane endpoint when AGENTCORE_STAGE=beta', async () => { + process.env.AGENTCORE_STAGE = 'beta'; + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('beta.us-west-2.elcapcp.genesis-primitives.aws.dev'), + expect.anything() + ); + }); + + it('uses gamma data plane endpoint when AGENTCORE_STAGE=gamma', async () => { + process.env.AGENTCORE_STAGE = 'gamma'; + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('gamma.us-west-2.elcapdp.genesis-primitives.aws.dev'), + expect.anything() + ); + }); + }); + + describe('request()', () => { + it('returns parsed JSON on success', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ harnessId: 'h-123' }), { status: 200 })); + + const result = await client.request({ method: 'GET', path: '/harnesses/h-123' }); + + expect(result).toEqual({ harnessId: 'h-123' }); + }); + + it('returns empty object on 204', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(null, { status: 204 })); + + const result = await client.request({ method: 'DELETE', path: '/harnesses/h-123' }); + + expect(result).toEqual({}); + }); + + it('throws AgentCoreApiError on non-2xx', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue( + new Response('{"message":"Not found"}', { + status: 404, + headers: { 'x-amzn-requestid': 'req-abc' }, + }) + ); + + const err = await client.request({ method: 'GET', path: '/harnesses/bad' }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AgentCoreApiError); + const apiErr = err as AgentCoreApiError; + expect(apiErr.statusCode).toBe(404); + expect(apiErr.requestId).toBe('req-abc'); + expect(apiErr.errorBody).toContain('Not found'); + }); + + it('sends JSON body when provided', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 201 })); + + await client.request({ method: 'POST', path: '/harnesses', body: { harnessName: 'test' } }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ harnessName: 'test' }) }) + ); + }); + + it('appends query parameters to URL', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/harnesses', query: { maxResults: '10' } }); + + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('maxResults=10'), expect.anything()); + }); + }); + + describe('requestRaw()', () => { + it('returns raw Response object', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' }); + const mockResponse = new Response('streaming data', { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const response = await client.requestRaw({ method: 'POST', path: '/harnesses/invoke' }); + + expect(response).toBe(mockResponse); + expect(response.status).toBe(200); + }); + + it('passes custom headers through', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' }); + mockFetch.mockResolvedValue(new Response('', { status: 200 })); + + await client.requestRaw({ + method: 'POST', + path: '/harnesses/invoke', + headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-123' }, + }); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + opts: expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-123', + }), + }), + }) + ); + }); + }); +}); diff --git a/src/cli/aws/__tests__/poll.test.ts b/src/cli/aws/__tests__/poll.test.ts new file mode 100644 index 000000000..2894758d8 --- /dev/null +++ b/src/cli/aws/__tests__/poll.test.ts @@ -0,0 +1,92 @@ +import { PollFailureError, PollTimeoutError, pollUntilTerminal } from '../poll.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +interface MockStatus { + status: string; + reason?: string; +} + +describe('pollUntilTerminal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns immediately when first result is terminal', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'READY' }); + + const result = await pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => r.status === 'READY', + }); + + expect(result).toEqual({ status: 'READY' }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('polls until terminal status is reached', async () => { + const fn = vi + .fn() + .mockResolvedValueOnce({ status: 'CREATING' }) + .mockResolvedValueOnce({ status: 'CREATING' }) + .mockResolvedValueOnce({ status: 'READY' }); + + const result = await pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => ['READY', 'FAILED'].includes(r.status), + intervalMs: 10, + }); + + expect(result).toEqual({ status: 'READY' }); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('throws PollFailureError when failure state is detected', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'FAILED', reason: 'bad config' }); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => ['READY', 'FAILED'].includes(r.status), + isFailure: (r: MockStatus) => r.status === 'FAILED', + getFailureReason: (r: MockStatus) => `Harness failed: ${r.reason}`, + intervalMs: 10, + }) + ).rejects.toThrow(PollFailureError); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => ['READY', 'FAILED'].includes(r.status), + isFailure: (r: MockStatus) => r.status === 'FAILED', + getFailureReason: (r: MockStatus) => `Harness failed: ${r.reason}`, + intervalMs: 10, + }) + ).rejects.toThrow('Harness failed: bad config'); + }); + + it('throws PollTimeoutError when maxWaitMs exceeded', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'CREATING' }); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => r.status === 'READY', + intervalMs: 10, + maxWaitMs: 50, + }) + ).rejects.toThrow(PollTimeoutError); + }); + + it('uses default failure message when getFailureReason is not provided', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'FAILED' }); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => r.status === 'FAILED', + isFailure: (r: MockStatus) => r.status === 'FAILED', + intervalMs: 10, + }) + ).rejects.toThrow('Resource entered a failed state'); + }); +}); diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts new file mode 100644 index 000000000..d070325fb --- /dev/null +++ b/src/cli/aws/agentcore-harness.ts @@ -0,0 +1,537 @@ +/** + * Typed client wrappers for Harness control plane and data plane operations. + * + * Control plane: CreateHarness, GetHarness, UpdateHarness, DeleteHarness, ListHarnesses + * Data plane: InvokeHarness (streaming) + * TODO InvokeAgentRuntimeCommand + * + * Built on AgentCoreApiClient (shared SigV4 HTTP client). + * Migrate to @aws-sdk/client-bedrock-agentcore-control when Harness commands land in the SDK. + */ +import { AgentCoreApiClient, AgentCoreApiError } from './api-client'; +import { randomUUID } from 'node:crypto'; + +// ============================================================================ +// Shared Types (from Smithy service model) +// ============================================================================ + +export type HarnessStatus = 'CREATING' | 'READY' | 'UPDATING' | 'DELETING' | 'DELETED' | 'FAILED'; + +export interface HarnessModelConfiguration { + bedrockModelConfig?: { modelId: string }; + anthropicModelConfig?: { modelId: string; apiKeyCredentialProviderArn?: string }; + openAIModelConfig?: { modelId: string; apiKeyCredentialProviderArn?: string }; + geminiModelConfig?: { modelId: string; apiKeyCredentialProviderArn?: string }; +} + +export type HarnessSystemPrompt = { text: string }[]; + +export interface HarnessTool { + type: string; + name: string; + browserArn?: string; + codeInterpreterArn?: string; + config?: Record; +} + +export interface HarnessSkill { + path: string; +} + +export interface HarnessMemoryConfiguration { + memoryArn?: string; +} + +export interface HarnessTruncationConfiguration { + strategy: string; + config: { slidingWindow?: { messagesCount: number } }; +} + +export interface HarnessEnvironmentArtifact { + containerConfiguration?: { containerUri: string }; +} + +export interface HarnessAgentCoreRuntimeEnvironment { + lifecycleConfiguration?: Record; + networkConfiguration?: Record; + filesystemConfigurations?: Record[]; +} + +export interface HarnessEnvironmentProvider { + agentCoreRuntimeEnvironment?: HarnessAgentCoreRuntimeEnvironment; +} + +export interface Harness { + harnessId: string; + harnessName: string; + arn: string; + status: HarnessStatus; + executionRoleArn: string; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + memory?: HarnessMemoryConfiguration; + truncation?: HarnessTruncationConfiguration; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + environment?: HarnessEnvironmentProvider; + environmentArtifact?: HarnessEnvironmentArtifact; + environmentVariables?: Record; + authorizerConfiguration?: Record; + tags?: Record; + createdAt: string; + updatedAt: string; +} + +export interface HarnessSummary { + harnessId: string; + harnessName: string; + arn: string; + status: HarnessStatus; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// CreateHarness +// ============================================================================ + +export interface CreateHarnessOptions { + region: string; + harnessName: string; + executionRoleArn: string; + environment?: HarnessEnvironmentProvider; + environmentArtifact?: HarnessEnvironmentArtifact; + environmentVariables?: Record; + authorizerConfiguration?: Record; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + memory?: HarnessMemoryConfiguration; + truncation?: HarnessTruncationConfiguration; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + tags?: Record; +} + +export interface CreateHarnessResult { + harness: Harness; +} + +export async function createHarness(options: CreateHarnessOptions): Promise { + const { region, ...rest } = options; + const client = new AgentCoreApiClient({ region, plane: 'control' }); + + const body: Record = { + harnessName: rest.harnessName, + clientToken: randomUUID(), + executionRoleArn: rest.executionRoleArn, + }; + + if (rest.environment) body.environment = rest.environment; + if (rest.environmentArtifact) body.environmentArtifact = rest.environmentArtifact; + if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables; + if (rest.authorizerConfiguration) body.authorizerConfiguration = rest.authorizerConfiguration; + if (rest.model) body.model = rest.model; + if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt; + if (rest.tools) body.tools = rest.tools; + if (rest.skills) body.skills = rest.skills; + if (rest.allowedTools) body.allowedTools = rest.allowedTools; + if (rest.memory) body.memory = rest.memory; + if (rest.truncation) body.truncation = rest.truncation; + if (rest.maxIterations != null) body.maxIterations = rest.maxIterations; + if (rest.maxTokens != null) body.maxTokens = rest.maxTokens; + if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds; + if (rest.tags) body.tags = rest.tags; + + const result = await client.request({ method: 'POST', path: '/harnesses', body }); + return result as CreateHarnessResult; +} + +// ============================================================================ +// GetHarness +// ============================================================================ + +export interface GetHarnessOptions { + region: string; + harnessId: string; +} + +export interface GetHarnessResult { + harness: Harness; +} + +export async function getHarness(options: GetHarnessOptions): Promise { + const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); + const result = await client.request({ method: 'GET', path: `/harnesses/${options.harnessId}` }); + return result as GetHarnessResult; +} + +// ============================================================================ +// UpdateHarness +// ============================================================================ + +export interface UpdateHarnessOptions { + region: string; + harnessId: string; + executionRoleArn?: string; + environment?: HarnessEnvironmentProvider; + environmentArtifact?: { optionalValue: HarnessEnvironmentArtifact | null }; + environmentVariables?: Record; + authorizerConfiguration?: { optionalValue: Record | null }; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + memory?: { optionalValue: HarnessMemoryConfiguration | null }; + truncation?: HarnessTruncationConfiguration; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + tags?: Record; +} + +export interface UpdateHarnessResult { + harness: Harness; +} + +export async function updateHarness(options: UpdateHarnessOptions): Promise { + const { region, harnessId, ...rest } = options; + const client = new AgentCoreApiClient({ region, plane: 'control' }); + + const body: Record = { + clientToken: randomUUID(), + }; + + if (rest.executionRoleArn) body.executionRoleArn = rest.executionRoleArn; + if (rest.environment) body.environment = rest.environment; + if (rest.environmentArtifact !== undefined) body.environmentArtifact = rest.environmentArtifact; + if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables; + if (rest.authorizerConfiguration !== undefined) body.authorizerConfiguration = rest.authorizerConfiguration; + if (rest.model) body.model = rest.model; + if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt; + if (rest.tools) body.tools = rest.tools; + if (rest.skills) body.skills = rest.skills; + if (rest.allowedTools) body.allowedTools = rest.allowedTools; + if (rest.memory !== undefined) body.memory = rest.memory; + if (rest.truncation) body.truncation = rest.truncation; + if (rest.maxIterations != null) body.maxIterations = rest.maxIterations; + if (rest.maxTokens != null) body.maxTokens = rest.maxTokens; + if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds; + if (rest.tags) body.tags = rest.tags; + + const result = await client.request({ method: 'PATCH', path: `/harnesses/${harnessId}`, body }); + return result as UpdateHarnessResult; +} + +// ============================================================================ +// DeleteHarness +// ============================================================================ + +export interface DeleteHarnessOptions { + region: string; + harnessId: string; +} + +export interface DeleteHarnessResult { + harness: Harness; +} + +export async function deleteHarness(options: DeleteHarnessOptions): Promise { + const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); + const result = await client.request({ + method: 'DELETE', + path: `/harnesses/${options.harnessId}`, + query: { clientToken: randomUUID() }, + }); + return result as DeleteHarnessResult; +} + +// ============================================================================ +// ListHarnesses +// ============================================================================ + +export interface ListHarnessesOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface ListHarnessesResult { + harnesses: HarnessSummary[]; + nextToken?: string; +} + +export async function listHarnesses(options: ListHarnessesOptions): Promise { + const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); + const query: Record = {}; + if (options.maxResults != null) query.maxResults = String(options.maxResults); + if (options.nextToken) query.nextToken = options.nextToken; + + const result = await client.request({ method: 'GET', path: '/harnesses', query }); + return result as ListHarnessesResult; +} + +export async function listAllHarnesses(region: string): Promise { + const all: HarnessSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listHarnesses({ region, maxResults: 100, nextToken }); + all.push(...result.harnesses); + nextToken = result.nextToken; + } while (nextToken); + + return all; +} + +// ============================================================================ +// InvokeHarness (streaming, data plane) +// ============================================================================ + +export interface InvokeHarnessOptions { + region: string; + harnessArn: string; + runtimeSessionId: string; + messages: { role: string; content: Record[] }[]; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + actorId?: string; +} + +// ── Stream event types ────────────────────────────────────────────────────── + +export type HarnessStopReason = + | 'end_turn' + | 'tool_use' + | 'tool_result' + | 'max_tokens' + | 'stop_sequence' + | 'content_filtered' + | 'malformed_model_output' + | 'malformed_tool_use' + | 'interrupted' + | 'partial_turn' + | 'model_context_window_exceeded' + | 'max_iterations_exceeded' + | 'max_output_tokens_exceeded' + | 'timeout_exceeded'; + +export interface ToolUseBlockStart { + toolUseId: string; + name: string; + type?: string; + serverName?: string; +} + +export interface ToolResultBlockStart { + toolUseId: string; + status?: string; +} + +export type ContentBlockStart = + | { type: 'toolUse'; toolUse: ToolUseBlockStart } + | { type: 'toolResult'; toolResult: ToolResultBlockStart }; + +export type ContentBlockDelta = + | { type: 'text'; text: string } + | { type: 'toolUse'; input: string } + | { type: 'toolResult'; results: Record[] } + | { type: 'reasoningContent'; text?: string; signature?: string }; + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cacheReadInputTokens?: number; + cacheWriteInputTokens?: number; +} + +export interface StreamMetrics { + latencyMs: number; +} + +export type HarnessStreamEvent = + | { type: 'messageStart'; role: string } + | { type: 'contentBlockStart'; contentBlockIndex: number; start: ContentBlockStart } + | { type: 'contentBlockDelta'; contentBlockIndex: number; delta: ContentBlockDelta } + | { type: 'contentBlockStop'; contentBlockIndex: number } + | { type: 'messageStop'; stopReason: HarnessStopReason } + | { type: 'metadata'; usage: TokenUsage; metrics: StreamMetrics } + | { type: 'error'; errorType: string; message: string }; + +export async function* invokeHarness(options: InvokeHarnessOptions): AsyncGenerator { + const { region, harnessArn, runtimeSessionId, messages, ...overrides } = options; + const client = new AgentCoreApiClient({ region, plane: 'data' }); + + const body: Record = { messages }; + if (overrides.model) body.model = overrides.model; + if (overrides.systemPrompt) body.systemPrompt = overrides.systemPrompt; + if (overrides.tools) body.tools = overrides.tools; + if (overrides.skills) body.skills = overrides.skills; + if (overrides.allowedTools) body.allowedTools = overrides.allowedTools; + if (overrides.maxIterations != null) body.maxIterations = overrides.maxIterations; + if (overrides.maxTokens != null) body.maxTokens = overrides.maxTokens; + if (overrides.timeoutSeconds != null) body.timeoutSeconds = overrides.timeoutSeconds; + if (overrides.actorId) body.actorId = overrides.actorId; + + const response = await client.requestRaw({ + method: 'POST', + path: '/harnesses/invoke', + query: { harnessArn }, + headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': runtimeSessionId }, + body, + }); + + if (!response.ok) { + const errorBody = await response.text(); + const requestId = response.headers.get('x-amzn-requestid') ?? undefined; + throw new AgentCoreApiError(response.status, errorBody, requestId); + } + + if (!response.body) return; + + yield* parseEventStream(response.body); +} + +async function* parseEventStream(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const event = parseLine(line); + if (event) yield event; + } + } + + if (buffer.trim()) { + const event = parseLine(buffer); + if (event) yield event; + } + } finally { + reader.releaseLock(); + } +} + +function parseLine(line: string): HarnessStreamEvent | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(':')) return null; + + let payload: Record; + const dataPrefix = 'data: '; + const raw = trimmed.startsWith(dataPrefix) ? trimmed.slice(dataPrefix.length) : trimmed; + + try { + payload = JSON.parse(raw) as Record; + } catch { + return null; + } + + if ('messageStart' in payload) { + const ms = payload.messageStart as { role: string }; + return { type: 'messageStart', role: ms.role }; + } + + if ('contentBlockStart' in payload) { + const cbs = payload.contentBlockStart as { contentBlockIndex: number; start: Record }; + return { + type: 'contentBlockStart', + contentBlockIndex: cbs.contentBlockIndex, + start: parseContentBlockStart(cbs.start), + }; + } + + if ('contentBlockDelta' in payload) { + const cbd = payload.contentBlockDelta as { contentBlockIndex: number; delta: Record }; + return { + type: 'contentBlockDelta', + contentBlockIndex: cbd.contentBlockIndex, + delta: parseContentBlockDelta(cbd.delta), + }; + } + + if ('contentBlockStop' in payload) { + const stop = payload.contentBlockStop as { contentBlockIndex: number }; + return { type: 'contentBlockStop', contentBlockIndex: stop.contentBlockIndex }; + } + + if ('messageStop' in payload) { + const ms = payload.messageStop as { stopReason: HarnessStopReason }; + return { type: 'messageStop', stopReason: ms.stopReason }; + } + + if ('metadata' in payload) { + const md = payload.metadata as { usage: TokenUsage; metrics: StreamMetrics }; + return { type: 'metadata', usage: md.usage, metrics: md.metrics }; + } + + if ('internalServerException' in payload) { + const ex = payload.internalServerException as { message?: string }; + return { type: 'error', errorType: 'internalServerException', message: ex.message ?? 'Internal server error' }; + } + + if ('validationException' in payload) { + const ex = payload.validationException as { message?: string }; + return { type: 'error', errorType: 'validationException', message: ex.message ?? 'Validation error' }; + } + + if ('runtimeClientError' in payload) { + const ex = payload.runtimeClientError as { message?: string }; + return { type: 'error', errorType: 'runtimeClientError', message: ex.message ?? 'Runtime client error' }; + } + + return null; +} + +function parseContentBlockStart(start: Record): ContentBlockStart { + if ('toolUse' in start) { + const tu = start.toolUse as ToolUseBlockStart; + return { type: 'toolUse', toolUse: tu }; + } + if ('toolResult' in start) { + const tr = start.toolResult as ToolResultBlockStart; + return { type: 'toolResult', toolResult: tr }; + } + return { type: 'toolUse', toolUse: { toolUseId: '', name: 'unknown' } }; +} + +function parseContentBlockDelta(delta: Record): ContentBlockDelta { + if ('text' in delta) { + return { type: 'text', text: delta.text as string }; + } + if ('toolUse' in delta) { + const tu = delta.toolUse as { input: string }; + return { type: 'toolUse', input: tu.input }; + } + if ('toolResult' in delta) { + return { type: 'toolResult', results: delta.toolResult as Record[] }; + } + if ('reasoningContent' in delta) { + const rc = delta.reasoningContent as { text?: string; signature?: string }; + return { type: 'reasoningContent', text: rc.text, signature: rc.signature }; + } + return { type: 'text', text: '' }; +} diff --git a/src/cli/aws/api-client.ts b/src/cli/aws/api-client.ts new file mode 100644 index 000000000..e9ecb066a --- /dev/null +++ b/src/cli/aws/api-client.ts @@ -0,0 +1,127 @@ +/** + * Shared SigV4-signed HTTP client for AgentCore control plane and data plane APIs. + * When the SDK adds native commands for new APIs, we will migrate callers to the SDK client. + */ +import { getCredentialProvider } from './account'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; + +const SERVICE = 'bedrock-agentcore'; + +export type ApiPlane = 'control' | 'data'; + +export interface ApiClientOptions { + region: string; + plane: ApiPlane; +} + +export interface RequestOptions { + method: string; + path: string; + body?: unknown; + query?: Record; + headers?: Record; +} + +export class AgentCoreApiError extends Error { + readonly statusCode: number; + readonly requestId: string | undefined; + readonly errorBody: string; + + constructor(statusCode: number, errorBody: string, requestId?: string) { + const reqIdSuffix = requestId ? ` [requestId: ${requestId}]` : ''; + super(`AgentCore API error (${statusCode}): ${errorBody}${reqIdSuffix}`); + this.name = 'AgentCoreApiError'; + this.statusCode = statusCode; + this.requestId = requestId; + this.errorBody = errorBody; + } +} + +export class AgentCoreApiClient { + private readonly region: string; + private readonly endpoint: string; + + constructor(options: ApiClientOptions) { + this.region = options.region; + this.endpoint = resolveEndpoint(options.region, options.plane); + } + + async request(options: RequestOptions): Promise { + const response = await this.requestRaw(options); + + if (!response.ok) { + const errorBody = await response.text(); + const requestId = response.headers.get('x-amzn-requestid') ?? undefined; + throw new AgentCoreApiError(response.status, errorBody, requestId); + } + + if (response.status === 204) return {}; + return response.json(); + } + + async requestRaw(options: RequestOptions): Promise { + const { method, path, body, query, headers: extraHeaders } = options; + + const url = new URL(path, this.endpoint); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + } + + const queryRecord: Record = {}; + url.searchParams.forEach((value, key) => { + queryRecord[key] = value; + }); + + const serializedBody = body != null ? JSON.stringify(body) : undefined; + + const httpRequest = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(queryRecord).length > 0 && { query: queryRecord }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + ...extraHeaders, + }, + ...(serializedBody && { body: serializedBody }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const signer = new SignatureV4({ + service: SERVICE, + region: this.region, + credentials, + sha256: Sha256, + }); + + const signed = await signer.sign(httpRequest); + + const fullUrl = `${this.endpoint}${url.pathname}${url.search}`; + return fetch(fullUrl, { + method, + headers: signed.headers as Record, + ...(serializedBody && { body: serializedBody }), + }); + } +} + +function resolveEndpoint(region: string, plane: ApiPlane): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + + if (plane === 'control') { + if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore-control.${region}.amazonaws.com`; + } + + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore.${region}.amazonaws.com`; +} diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index c3789692b..98da5845b 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -24,6 +24,35 @@ export { type GetPolicyGenerationOptions, type GetPolicyGenerationResult, } from './policy-generation'; +export { AgentCoreApiClient, AgentCoreApiError, type ApiClientOptions, type ApiPlane } from './api-client'; +export { pollUntilTerminal, PollTimeoutError, PollFailureError, type PollOptions } from './poll'; +export { + createHarness, + getHarness, + updateHarness, + deleteHarness, + listHarnesses, + listAllHarnesses, + invokeHarness, + type Harness, + type HarnessSummary, + type HarnessStatus, + type HarnessStreamEvent, + type HarnessStopReason, + type TokenUsage, + type StreamMetrics, + type CreateHarnessOptions, + type CreateHarnessResult, + type GetHarnessOptions, + type GetHarnessResult, + type UpdateHarnessOptions, + type UpdateHarnessResult, + type DeleteHarnessOptions, + type DeleteHarnessResult, + type ListHarnessesOptions, + type ListHarnessesResult, + type InvokeHarnessOptions, +} from './agentcore-harness'; export { DEFAULT_RUNTIME_USER_ID, executeBashCommand, diff --git a/src/cli/aws/poll.ts b/src/cli/aws/poll.ts new file mode 100644 index 000000000..0adfaca23 --- /dev/null +++ b/src/cli/aws/poll.ts @@ -0,0 +1,47 @@ +/** + * Generic polling utility for async AWS resource status transitions. + */ + +export interface PollOptions { + fn: () => Promise; + isTerminal: (result: T) => boolean; + isFailure?: (result: T) => boolean; + getFailureReason?: (result: T) => string; + intervalMs?: number; + maxWaitMs?: number; +} + +export class PollTimeoutError extends Error { + constructor(maxWaitMs: number) { + super(`Polling timed out after ${maxWaitMs}ms`); + this.name = 'PollTimeoutError'; + } +} + +export class PollFailureError extends Error { + constructor(reason: string) { + super(reason); + this.name = 'PollFailureError'; + } +} + +export async function pollUntilTerminal(options: PollOptions): Promise { + const { fn, isTerminal, isFailure, getFailureReason, intervalMs = 3000, maxWaitMs = 120_000 } = options; + const start = Date.now(); + + while (Date.now() - start < maxWaitMs) { + const result = await fn(); + + if (isTerminal(result)) { + if (isFailure?.(result)) { + const reason = getFailureReason?.(result) ?? 'Resource entered a failed state'; + throw new PollFailureError(reason); + } + return result; + } + + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + throw new PollTimeoutError(maxWaitMs); +} From 9151a14133d92e5c8352240d0bd216ceaeeb3202 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:26:20 -0400 Subject: [PATCH 07/49] feat(schema): add Harness primitive schema definitions (#85) * feat(schema): add HarnessSpec schema for per-harness config Adds Zod schema validation for harness.json config files. Includes: - HarnessSpec: top-level schema for agent-as-config harness definitions - Model config: supports Bedrock, OpenAI, Gemini providers with inference params - Tool config: remote MCP, AgentCore browser/gateway/code-interpreter, inline functions - Memory, truncation, lifecycle, network, and container configuration - Comprehensive test suite with 62 test cases covering all schema variants This is the first of 5 tasks to add harness support to the AgentCore CLI schema layer. * feat(schema): add harnesses[] registry to AgentCoreProjectSpec * feat(schema): wire harness exports through index files * feat(schema): add HarnessDeployedState to deployed-state * chore(schema): regenerate JSON schema with harnesses registry * fix: validate tool config matches tool type and topK is gemini-only Add superRefine to HarnessToolSchema that enforces the correct config key for each tool type (e.g. remote_mcp requires remoteMcp config) and rejects tools that require config when none is provided. Also restrict topK inference parameter to the gemini provider only, matching the API model where only GeminiModelConfig has topK. --- schemas/agentcore.schema.v1.json | 33 +- .../commands/logs/__tests__/action.test.ts | 4 + src/cli/commands/remove/command.tsx | 1 + .../__tests__/checks-extended.test.ts | 10 + .../agent/generate/write-agent-to-project.ts | 1 + .../operations/dev/__tests__/config.test.ts | 21 + .../__tests__/GatewayPrimitive.test.ts | 1 + .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/project.ts | 1 + .../__tests__/agentcore-project.test.ts | 57 ++ .../schemas/__tests__/deployed-state.test.ts | 34 + src/schema/schemas/agentcore-project.ts | 29 + src/schema/schemas/deployed-state.ts | 15 + .../primitives/__tests__/harness.test.ts | 603 ++++++++++++++++++ src/schema/schemas/primitives/harness.ts | 255 ++++++++ src/schema/schemas/primitives/index.ts | 24 + 16 files changed, 1084 insertions(+), 6 deletions(-) create mode 100644 src/schema/schemas/primitives/__tests__/harness.test.ts create mode 100644 src/schema/schemas/primitives/harness.ts diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 3ccb8002e..aad1ac96f 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -74,7 +74,7 @@ "anyOf": [ { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, { "type": "string", @@ -774,7 +774,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "timeout": { "type": "integer", @@ -839,7 +839,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "name": { "type": "string", @@ -1269,7 +1269,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "name": { "type": "string", @@ -1427,7 +1427,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "timeout": { "type": "integer", @@ -1492,7 +1492,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "name": { "type": "string", @@ -1783,6 +1783,27 @@ "required": ["name"], "additionalProperties": false } + }, + "harnesses": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 48, + "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$" + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "path"], + "additionalProperties": false + } } }, "required": ["name", "version"], diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 039acfb67..4c8b40162 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -60,6 +60,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, deployedState: { targets: { @@ -121,6 +122,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); @@ -162,6 +164,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, deployedState: { targets: { @@ -213,6 +216,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index deb1a9274..54f3c158f 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -34,6 +34,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(true); }); @@ -78,6 +79,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -94,6 +96,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -121,6 +124,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -146,6 +150,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -162,6 +167,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -195,6 +201,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -262,6 +269,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -282,6 +290,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -310,6 +319,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 26750d279..8c05a52ea 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -72,6 +72,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index b6967ac6e..c7a681553 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -21,6 +21,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -48,6 +49,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -75,6 +77,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -108,6 +111,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -136,6 +140,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -162,6 +167,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -191,6 +197,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; // No configRoot provided @@ -220,6 +227,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -249,6 +257,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -277,6 +286,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -305,6 +315,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -333,6 +344,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -361,6 +373,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -390,6 +403,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -432,6 +446,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -450,6 +465,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -473,6 +489,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -499,6 +516,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -533,6 +551,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -561,6 +580,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -597,6 +617,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 4c4c66402..17a3f0f2c 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -13,6 +13,7 @@ const defaultProject: AgentCoreProjectSpec = { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 3fce5148f..2eca7c1a7 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -93,6 +93,7 @@ describe('createManagedOAuthCredential', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/project.ts b/src/cli/project.ts index d588b8ca6..b5cc89bfb 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,6 +18,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 854959b6c..2b77e50b4 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -2,6 +2,7 @@ import { AgentCoreProjectSpecSchema, CredentialNameSchema, CredentialSchema, + HarnessRegistryEntrySchema, MemoryNameSchema, MemorySchema, ProjectNameSchema, @@ -416,6 +417,18 @@ describe('CredentialSchema', () => { }); }); +describe('HarnessRegistryEntrySchema', () => { + it('accepts valid entry', () => { + const result = HarnessRegistryEntrySchema.safeParse({ name: 'myHarness', path: './harnesses/myHarness' }); + expect(result.success).toBe(true); + }); + + it('rejects name starting with digit', () => { + const result = HarnessRegistryEntrySchema.safeParse({ name: '1harness', path: './harnesses/1harness' }); + expect(result.success).toBe(false); + }); +}); + describe('AgentCoreProjectSpecSchema', () => { const minimalProject = { name: 'TestProject', @@ -561,4 +574,48 @@ describe('AgentCoreProjectSpecSchema', () => { }); expect(result.success).toBe(false); }); + + it('accepts project with harnesses array', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [{ name: 'myHarness', path: './harnesses/myHarness' }], + }); + expect(result.success).toBe(true); + }); + + it('defaults harnesses to empty array', () => { + const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.harnesses).toEqual([]); + } + }); + + it('rejects duplicate harness names', () => { + const harness = { name: 'myHarness', path: './harnesses/myHarness' }; + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [harness, harness], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate harness name'))).toBe(true); + } + }); + + it('rejects harness with empty name', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [{ name: '', path: './harnesses/empty' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects harness with empty path', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [{ name: 'myHarness', path: '' }], + }); + expect(result.success).toBe(false); + }); }); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index e468e9e85..6ffcd7f29 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -5,6 +5,7 @@ import { DeployedResourceStateSchema, DeployedStateSchema, GatewayDeployedStateSchema, + HarnessDeployedStateSchema, McpDeployedStateSchema, McpLambdaDeployedStateSchema, McpRuntimeDeployedStateSchema, @@ -286,6 +287,39 @@ describe('DeployedStateSchema', () => { }); }); +describe('HarnessDeployedStateSchema', () => { + it('accepts valid harness deployed state', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty harnessId', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: '', + harnessArn: 'arn:aws:test', + roleArn: 'arn:aws:test', + status: 'READY', + }); + expect(result.success).toBe(false); + }); + + it('accepts optional memoryArn', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + memoryArn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/def456', + }); + expect(result.success).toBe(true); + }); +}); + describe('createValidatedDeployedStateSchema', () => { it('accepts state with targets matching known target names', () => { const schema = createValidatedDeployedStateSchema(['dev', 'prod']); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 8d0f35f1c..f82e46922 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -10,6 +10,7 @@ import { isReservedProjectName } from '../constants'; import { AgentEnvSpecSchema } from './agent-env'; import { AgentCoreGatewaySchema, AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema } from './mcp'; import { EvaluationLevelSchema, EvaluatorConfigSchema, EvaluatorNameSchema } from './primitives/evaluator'; +import { HarnessNameSchema } from './primitives/harness'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES, @@ -43,6 +44,13 @@ export type { RatingScale, } from './primitives/evaluator'; export { BedrockModelIdSchema, isValidBedrockModelId, EvaluatorNameSchema } from './primitives/evaluator'; +export type { HarnessSpec } from './primitives/harness'; +export { + HarnessNameSchema, + HarnessSpecSchema, + HarnessToolTypeSchema, + HarnessModelProviderSchema, +} from './primitives/harness'; export { PolicyEngineSchema }; export type { Policy, PolicyEngine, ValidationMode } from './primitives/policy'; export { PolicyEngineNameSchema, PolicyNameSchema, PolicySchema, ValidationModeSchema } from './primitives/policy'; @@ -205,6 +213,17 @@ export const EvaluatorSchema = z.object({ export type Evaluator = z.infer; +// ============================================================================ +// Harness Registry Schema +// ============================================================================ + +export const HarnessRegistryEntrySchema = z.object({ + name: HarnessNameSchema, + path: z.string().min(1, 'Path to harness config directory is required'), +}); + +export type HarnessRegistryEntry = z.infer; + // ============================================================================ // Project Schema (Top Level) // ============================================================================ @@ -312,6 +331,16 @@ export const AgentCoreProjectSpecSchema = z name => `Duplicate policy engine name: ${name}` ) ), + + harnesses: z + .array(HarnessRegistryEntrySchema) + .default([]) + .superRefine( + uniqueBy( + harness => harness.name, + name => `Duplicate harness name: ${name}` + ) + ), }) .strict() .superRefine((spec, ctx) => { diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 2454d6b09..65d85fb58 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -133,6 +133,20 @@ export const PolicyDeployedStateSchema = z.object({ export type PolicyDeployedState = z.infer; +// ============================================================================ +// Harness Deployed State +// ============================================================================ + +export const HarnessDeployedStateSchema = z.object({ + harnessId: z.string().min(1), + harnessArn: z.string().min(1), + roleArn: z.string().min(1), + status: z.string().min(1), + memoryArn: z.string().optional(), +}); + +export type HarnessDeployedState = z.infer; + // ============================================================================ // Credential Deployed State // ============================================================================ @@ -182,6 +196,7 @@ export const DeployedResourceStateSchema = z.object({ onlineEvalConfigs: z.record(z.string(), OnlineEvalDeployedStateSchema).optional(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), + harnesses: z.record(z.string(), HarnessDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), }); diff --git a/src/schema/schemas/primitives/__tests__/harness.test.ts b/src/schema/schemas/primitives/__tests__/harness.test.ts new file mode 100644 index 000000000..de68d6ac0 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/harness.test.ts @@ -0,0 +1,603 @@ +import { + HarnessModelProviderSchema, + HarnessModelSchema, + HarnessNameSchema, + HarnessSpecSchema, + HarnessToolSchema, + HarnessToolTypeSchema, +} from '../harness'; +import { describe, expect, it } from 'vitest'; + +describe('HarnessNameSchema', () => { + it.each(['MyHarness', 'a', 'Agent1', 'my_harness_01'])('accepts valid name "%s"', name => { + expect(HarnessNameSchema.safeParse(name).success).toBe(true); + }); + + it('accepts 48-character name (max)', () => { + const name = 'A' + 'b'.repeat(47); + expect(name).toHaveLength(48); + expect(HarnessNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects 49-character name', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(HarnessNameSchema.safeParse(name).success).toBe(false); + }); + + it('rejects empty string', () => { + expect(HarnessNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(HarnessNameSchema.safeParse('1harness').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(HarnessNameSchema.safeParse('my-harness').success).toBe(false); + }); + + it('rejects name with spaces', () => { + expect(HarnessNameSchema.safeParse('my harness').success).toBe(false); + }); +}); + +describe('HarnessToolTypeSchema', () => { + it.each(['remote_mcp', 'agentcore_browser', 'agentcore_gateway', 'inline_function', 'agentcore_code_interpreter'])( + 'accepts "%s"', + type => { + expect(HarnessToolTypeSchema.safeParse(type).success).toBe(true); + } + ); + + it('rejects unknown tool type', () => { + expect(HarnessToolTypeSchema.safeParse('unknown_tool').success).toBe(false); + }); +}); + +describe('HarnessModelProviderSchema', () => { + it.each(['bedrock', 'open_ai', 'gemini'])('accepts "%s"', provider => { + expect(HarnessModelProviderSchema.safeParse(provider).success).toBe(true); + }); + + it('rejects unknown provider', () => { + expect(HarnessModelProviderSchema.safeParse('azure').success).toBe(false); + }); +}); + +describe('HarnessToolSchema', () => { + it('accepts browser tool with no config', () => { + const result = HarnessToolSchema.safeParse({ type: 'agentcore_browser', name: 'browser' }); + expect(result.success).toBe(true); + }); + + it('accepts browser tool with optional browserArn', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'browser', + config: { agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-west-2:123:browser/abc' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts code interpreter tool with no config', () => { + const result = HarnessToolSchema.safeParse({ type: 'agentcore_code_interpreter', name: 'code-interp' }); + expect(result.success).toBe(true); + }); + + it('accepts remote MCP tool with url', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'exa', + config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts remote MCP tool with headers', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'exa', + config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp', headers: { Authorization: 'Bearer tok' } } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool with gatewayArn', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool with credentialProviderName', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + credentialProviderName: 'my-oauth', + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts inline function tool', () => { + const result = HarnessToolSchema.safeParse({ + type: 'inline_function', + name: 'approve_purchase', + config: { + inlineFunction: { + description: 'Approve a purchase', + inputSchema: { + type: 'object', + properties: { amount: { type: 'number' } }, + required: ['amount'], + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects tool name longer than 64 chars', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'a'.repeat(65), + }); + expect(result.success).toBe(false); + }); + + it('rejects tool name with invalid characters', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'my tool!', + }); + expect(result.success).toBe(false); + }); + + it('rejects remote_mcp with agentCoreBrowser config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'mcp-server', + config: { agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-west-2:123:browser/abc' } }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('requires "remoteMcp" config'))).toBe(true); + } + }); + + it('rejects agentcore_gateway without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('requires a "agentCoreGateway" config'))).toBe(true); + } + }); + + it('rejects remote_mcp without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'exa', + }); + expect(result.success).toBe(false); + }); + + it('rejects inline_function without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'inline_function', + name: 'my-func', + }); + expect(result.success).toBe(false); + }); + + it('rejects agentcore_gateway with remoteMcp config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { remoteMcp: { url: 'https://example.com' } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects inline_function with agentCoreGateway config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'inline_function', + name: 'my-func', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }); + expect(result.success).toBe(false); + }); + + it('allows agentcore_browser without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'browser', + }); + expect(result.success).toBe(true); + }); + + it('allows agentcore_code_interpreter without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_code_interpreter', + name: 'code-interp', + }); + expect(result.success).toBe(true); + }); +}); + +describe('HarnessModelSchema', () => { + it('accepts bedrock model with just modelId', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }); + expect(result.success).toBe(true); + }); + + it('accepts bedrock model with optional inference params', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + temperature: 0.7, + topP: 0.9, + maxTokens: 4096, + }); + expect(result.success).toBe(true); + }); + + it('accepts open_ai model with apiKeyArn', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + }); + expect(result.success).toBe(true); + }); + + it('accepts gemini model with topK', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'gemini', + modelId: 'gemini-2.5-pro', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + topK: 0.5, + }); + expect(result.success).toBe(true); + }); + + it('rejects temperature above 2.0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + temperature: 2.1, + }); + expect(result.success).toBe(false); + }); + + it('rejects temperature below 0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + temperature: -0.1, + }); + expect(result.success).toBe(false); + }); + + it('rejects topP above 1.0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + topP: 1.1, + }); + expect(result.success).toBe(false); + }); + + it('rejects maxTokens of 0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + maxTokens: 0, + }); + expect(result.success).toBe(false); + }); + + it('requires modelId', () => { + const result = HarnessModelSchema.safeParse({ provider: 'bedrock' }); + expect(result.success).toBe(false); + }); + + it('rejects topK for bedrock provider', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + topK: 0.5, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some(i => i.message.includes('topK is only supported for the "gemini" provider')) + ).toBe(true); + } + }); + + it('rejects topK for open_ai provider', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + topK: 0.5, + }); + expect(result.success).toBe(false); + }); +}); + +describe('HarnessSpecSchema', () => { + const minimalHarness = { + name: 'myHarness', + model: { + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }, + }; + + it('accepts minimal harness spec', () => { + const result = HarnessSpecSchema.safeParse(minimalHarness); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tools).toEqual([]); + expect(result.data.skills).toEqual([]); + } + }); + + it('accepts harness with system prompt file path', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + systemPrompt: './system-prompt.md', + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with tools', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + tools: [ + { type: 'agentcore_browser', name: 'browser' }, + { type: 'remote_mcp', name: 'exa', config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp' } } }, + { + type: 'agentcore_gateway', + name: 'my-gw', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects duplicate tool names', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + tools: [ + { type: 'agentcore_browser', name: 'browser' }, + { type: 'agentcore_code_interpreter', name: 'browser' }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate tool name'))).toBe(true); + } + }); + + it('accepts harness with skills as string paths', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + skills: ['./skills/research', '.agents/skills/xlsx'], + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with allowedTools', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + allowedTools: ['file_operations', 'browser'], + }); + expect(result.success).toBe(true); + }); + + it('accepts wildcard in allowedTools', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + allowedTools: ['*'], + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with memory reference', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { name: 'research_memory' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with memory arn override', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { arn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/abc' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with execution limits', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + maxIterations: 50, + timeoutSeconds: 1800, + maxTokens: 8192, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with sliding_window truncation', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { + strategy: 'sliding_window', + config: { slidingWindow: { messagesCount: 100 } }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with summarization truncation', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { + strategy: 'summarization', + config: { summarization: { summaryRatio: 0.3, preserveRecentMessages: 10 } }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects unknown truncation strategy', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { strategy: 'random', config: {} }, + }); + expect(result.success).toBe(false); + }); + + it('accepts harness with container config', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + containerUri: '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agent:latest', + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with dockerfile', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + dockerfile: 'Dockerfile', + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with VPC network config', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + networkMode: 'VPC', + networkConfig: { + subnets: ['subnet-abc12345'], + securityGroups: ['sg-abc12345'], + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects VPC mode without networkConfig', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + networkMode: 'VPC', + }); + expect(result.success).toBe(false); + }); + + it('rejects networkConfig without VPC mode', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + networkConfig: { + subnets: ['subnet-abc12345'], + securityGroups: ['sg-abc12345'], + }, + }); + expect(result.success).toBe(false); + }); + + it('accepts harness with lifecycle config', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + lifecycleConfig: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28800, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with environment variables', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + environmentVariables: { NODE_ENV: 'production', DEBUG: 'true' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with tags', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + tags: { team: 'platform', env: 'dev' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with executionRoleArn', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + executionRoleArn: 'arn:aws:iam::123456789012:role/MyRole', + }); + expect(result.success).toBe(true); + }); + + it('accepts fully-loaded harness spec', () => { + const result = HarnessSpecSchema.safeParse({ + name: 'research_agent', + model: { + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + temperature: 0.7, + maxTokens: 4096, + }, + systemPrompt: './system-prompt.md', + tools: [ + { type: 'agentcore_browser', name: 'browser' }, + { type: 'agentcore_code_interpreter', name: 'code_interpreter' }, + { type: 'remote_mcp', name: 'exa', config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp' } } }, + { + type: 'agentcore_gateway', + name: 'my_gateway', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }, + { + type: 'inline_function', + name: 'approve_purchase', + config: { + inlineFunction: { + description: 'Approve a purchase', + inputSchema: { type: 'object', properties: { amount: { type: 'number' } }, required: ['amount'] }, + }, + }, + }, + ], + skills: ['./skills/research'], + allowedTools: ['*'], + memory: { name: 'research_memory' }, + maxIterations: 75, + timeoutSeconds: 3600, + maxTokens: 16384, + truncation: { strategy: 'sliding_window', config: { slidingWindow: { messagesCount: 150 } } }, + lifecycleConfig: { idleRuntimeSessionTimeout: 900 }, + networkMode: 'PUBLIC', + tags: { team: 'research' }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts new file mode 100644 index 000000000..da275e178 --- /dev/null +++ b/src/schema/schemas/primitives/harness.ts @@ -0,0 +1,255 @@ +import { NetworkModeSchema } from '../../constants'; +import { NetworkConfigSchema } from '../agent-env'; +import { LifecycleConfigurationSchema } from '../agent-env'; +import { uniqueBy } from '../zod-util'; +import { TagsSchema } from './tags'; +import { z } from 'zod'; + +// ============================================================================ +// Harness Name +// ============================================================================ + +export const HarnessNameSchema = z + .string() + .min(1, 'Harness name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +// ============================================================================ +// Model Configuration +// ============================================================================ + +export const HarnessModelProviderSchema = z.enum(['bedrock', 'open_ai', 'gemini']); +export type HarnessModelProvider = z.infer; + +export const HarnessModelSchema = z + .object({ + provider: HarnessModelProviderSchema, + modelId: z.string().min(1, 'Model ID is required'), + apiKeyArn: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + topP: z.number().min(0).max(1).optional(), + topK: z.number().min(0).max(1).optional(), + maxTokens: z.number().int().min(1).optional(), + }) + .superRefine((model, ctx) => { + if (model.topK !== undefined && model.provider !== 'gemini') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'topK is only supported for the "gemini" provider', + path: ['topK'], + }); + } + }); + +export type HarnessModel = z.infer; + +// ============================================================================ +// Tool Configuration +// ============================================================================ + +export const HarnessToolTypeSchema = z.enum([ + 'remote_mcp', + 'agentcore_browser', + 'agentcore_gateway', + 'inline_function', + 'agentcore_code_interpreter', +]); +export type HarnessToolType = z.infer; + +export const HarnessToolNameSchema = z + .string() + .min(1) + .max(64) + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Tool name must contain only alphanumeric characters, hyphens, and underscores (1-64 chars)' + ); + +export const RemoteMcpConfigSchema = z.object({ + remoteMcp: z.object({ + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + }), +}); + +export const AgentCoreBrowserConfigSchema = z.object({ + agentCoreBrowser: z.object({ + browserArn: z.string().optional(), + }), +}); + +export const AgentCoreCodeInterpreterConfigSchema = z.object({ + agentCoreCodeInterpreter: z.object({ + codeInterpreterArn: z.string().optional(), + }), +}); + +export const AgentCoreGatewayConfigSchema = z.object({ + agentCoreGateway: z.object({ + gatewayArn: z.string().min(1), + credentialProviderName: z.string().optional(), + }), +}); + +export const InlineFunctionConfigSchema = z.object({ + inlineFunction: z.object({ + description: z.string().min(1), + inputSchema: z.record(z.string(), z.unknown()), + }), +}); + +export const HarnessToolConfigSchema = z.union([ + RemoteMcpConfigSchema, + AgentCoreBrowserConfigSchema, + AgentCoreCodeInterpreterConfigSchema, + AgentCoreGatewayConfigSchema, + InlineFunctionConfigSchema, +]); + +const TOOL_TYPE_TO_CONFIG_KEY: Record = { + remote_mcp: 'remoteMcp', + agentcore_browser: 'agentCoreBrowser', + agentcore_gateway: 'agentCoreGateway', + inline_function: 'inlineFunction', + agentcore_code_interpreter: 'agentCoreCodeInterpreter', +}; + +const TOOL_TYPES_REQUIRING_CONFIG = new Set(['remote_mcp', 'agentcore_gateway', 'inline_function']); + +export const HarnessToolSchema = z + .object({ + type: HarnessToolTypeSchema, + name: HarnessToolNameSchema, + config: HarnessToolConfigSchema.optional(), + }) + .superRefine((tool, ctx) => { + const expectedKey = TOOL_TYPE_TO_CONFIG_KEY[tool.type]; + + if (!tool.config) { + if (TOOL_TYPES_REQUIRING_CONFIG.has(tool.type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Tool type "${tool.type}" requires a "${expectedKey}" config`, + path: ['config'], + }); + } + return; + } + + const configKeys = Object.keys(tool.config); + if (configKeys.length !== 1 || configKeys[0] !== expectedKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Tool type "${tool.type}" requires "${expectedKey}" config, got "${configKeys[0]}"`, + path: ['config'], + }); + } + }); + +export type HarnessTool = z.infer; + +// ============================================================================ +// Memory Reference +// ============================================================================ + +export const HarnessMemoryRefSchema = z.object({ + name: z.string().min(1).optional(), + arn: z.string().min(1).optional(), + actorId: z.string().optional(), +}); + +export type HarnessMemoryRef = z.infer; + +// ============================================================================ +// Truncation Configuration +// ============================================================================ + +export const HarnessTruncationStrategySchema = z.enum(['sliding_window', 'summarization']); + +export const SlidingWindowConfigSchema = z.object({ + slidingWindow: z.object({ + messagesCount: z.number().int().min(1).optional(), + }), +}); + +export const SummarizationConfigSchema = z.object({ + summarization: z.object({ + summaryRatio: z.number().min(0).max(1).optional(), + preserveRecentMessages: z.number().int().min(0).optional(), + summarizationSystemPrompt: z.string().optional(), + }), +}); + +export const HarnessTruncationConfigSchema = z.object({ + strategy: HarnessTruncationStrategySchema, + config: z.union([SlidingWindowConfigSchema, SummarizationConfigSchema]).optional(), +}); + +export type HarnessTruncationConfig = z.infer; + +// ============================================================================ +// Allowed Tools +// ============================================================================ + +export const AllowedToolSchema = z + .string() + .min(1) + .max(64) + .regex(/^(\*|@?[^/]+(\/[^/]+)?)$/, 'Must be "*" or a tool name pattern (max 64 chars)'); + +// ============================================================================ +// HarnessSpec — per-harness config file schema (harness.json) +// ============================================================================ + +export const HarnessSpecSchema = z + .object({ + name: HarnessNameSchema, + model: HarnessModelSchema, + systemPrompt: z.string().optional(), + tools: z + .array(HarnessToolSchema) + .default([]) + .superRefine( + uniqueBy( + tool => tool.name, + name => `Duplicate tool name: ${name}` + ) + ), + skills: z.array(z.string().min(1)).default([]), + allowedTools: z.array(AllowedToolSchema).optional(), + memory: HarnessMemoryRefSchema.optional(), + maxIterations: z.number().int().min(1).optional(), + maxTokens: z.number().int().min(1).optional(), + timeoutSeconds: z.number().int().min(1).optional(), + truncation: HarnessTruncationConfigSchema.optional(), + containerUri: z.string().optional(), + dockerfile: z.string().optional(), + executionRoleArn: z.string().optional(), + networkMode: NetworkModeSchema.optional(), + networkConfig: NetworkConfigSchema.optional(), + lifecycleConfig: LifecycleConfigurationSchema.optional(), + environmentVariables: z.record(z.string(), z.string()).optional(), + tags: TagsSchema.optional(), + }) + .superRefine((data, ctx) => { + if (data.networkMode === 'VPC' && !data.networkConfig) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'networkConfig is required when networkMode is VPC', + path: ['networkConfig'], + }); + } + if (data.networkMode !== 'VPC' && data.networkConfig) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'networkConfig is only allowed when networkMode is VPC', + path: ['networkConfig'], + }); + } + }); + +export type HarnessSpec = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index e14a0f248..0df1f3c3e 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -44,3 +44,27 @@ export { PolicySchema, ValidationModeSchema, } from './policy'; + +export type { + HarnessMemoryRef, + HarnessModel, + HarnessModelProvider, + HarnessSpec, + HarnessTool, + HarnessToolType, + HarnessTruncationConfig, +} from './harness'; +export { + AllowedToolSchema, + HarnessMemoryRefSchema, + HarnessModelProviderSchema, + HarnessModelSchema, + HarnessNameSchema, + HarnessSpecSchema, + HarnessToolConfigSchema, + HarnessToolNameSchema, + HarnessToolSchema, + HarnessToolTypeSchema, + HarnessTruncationConfigSchema, + HarnessTruncationStrategySchema, +} from './harness'; From 62c432ca722be108f8bb2528105d96c3529073eb Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:43:02 -0400 Subject: [PATCH 08/49] fix: make harnesses field optional in AgentCoreProjectSpec (#86) The harnesses registry in agentcore.json should be optional, not defaulting to an empty array. Existing projects without harnesses should not need to include the field. Remove harnesses: [] from all production code and test fixtures that were previously required by the .default([]) behavior. --- schemas/agentcore.schema.v1.json | 1 - .../commands/logs/__tests__/action.test.ts | 4 ---- src/cli/commands/remove/command.tsx | 1 - .../__tests__/checks-extended.test.ts | 10 --------- .../agent/generate/write-agent-to-project.ts | 1 - .../operations/dev/__tests__/config.test.ts | 21 ------------------- .../__tests__/GatewayPrimitive.test.ts | 1 - .../primitives/__tests__/auth-utils.test.ts | 1 - src/cli/project.ts | 2 +- .../__tests__/agentcore-project.test.ts | 4 ++-- src/schema/schemas/agentcore-project.ts | 21 ++++++++++++------- 11 files changed, 17 insertions(+), 50 deletions(-) diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index aad1ac96f..3f661a66b 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -1785,7 +1785,6 @@ } }, "harnesses": { - "default": [], "type": "array", "items": { "type": "object", diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 4c8b40162..039acfb67 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -60,7 +60,6 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }, deployedState: { targets: { @@ -122,7 +121,6 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }, }); const result = resolveAgentContext(context, {}); @@ -164,7 +162,6 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }, deployedState: { targets: { @@ -216,7 +213,6 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 54f3c158f..deb1a9274 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -34,7 +34,6 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresUv(project)).toBe(true); }); @@ -79,7 +78,6 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -96,7 +94,6 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -124,7 +121,6 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -150,7 +146,6 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -167,7 +162,6 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -201,7 +195,6 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -269,7 +262,6 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const result = await checkDependencyVersions(project); @@ -290,7 +282,6 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const result = await checkDependencyVersions(project); @@ -319,7 +310,6 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 8c05a52ea..26750d279 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -72,7 +72,6 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index c7a681553..b6967ac6e 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -21,7 +21,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -49,7 +48,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -77,7 +75,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -111,7 +108,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -140,7 +136,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -167,7 +162,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -197,7 +191,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; // No configRoot provided @@ -227,7 +220,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -257,7 +249,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -286,7 +277,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -315,7 +305,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -344,7 +333,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -373,7 +361,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -403,7 +390,6 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -446,7 +432,6 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -465,7 +450,6 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -489,7 +473,6 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -516,7 +499,6 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -551,7 +533,6 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -580,7 +561,6 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -617,7 +597,6 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 17a3f0f2c..4c4c66402 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -13,7 +13,6 @@ const defaultProject: AgentCoreProjectSpec = { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 2eca7c1a7..3fce5148f 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -93,7 +93,6 @@ describe('createManagedOAuthCredential', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/project.ts b/src/cli/project.ts index b5cc89bfb..c66d650cb 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,7 +18,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - harnesses: [], + tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 2b77e50b4..b272d76e2 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -583,11 +583,11 @@ describe('AgentCoreProjectSpecSchema', () => { expect(result.success).toBe(true); }); - it('defaults harnesses to empty array', () => { + it('omits harnesses when not provided', () => { const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); expect(result.success).toBe(true); if (result.success) { - expect(result.data.harnesses).toEqual([]); + expect(result.data.harnesses).toBeUndefined(); } }); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index f82e46922..b34acd267 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -334,13 +334,20 @@ export const AgentCoreProjectSpecSchema = z harnesses: z .array(HarnessRegistryEntrySchema) - .default([]) - .superRefine( - uniqueBy( - harness => harness.name, - name => `Duplicate harness name: ${name}` - ) - ), + .optional() + .superRefine((harnesses, ctx) => { + if (!harnesses) return; + const seen = new Set(); + for (const harness of harnesses) { + if (seen.has(harness.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate harness name: ${harness.name}`, + }); + } + seen.add(harness.name); + } + }), }) .strict() .superRefine((spec, ctx) => { From cf762bd11ad1ca6627abecdbda4eb901329096c4 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:26:56 -0400 Subject: [PATCH 09/49] feat: harness deploy support (#87) * feat: vended CDK app creates harness execution roles Reads harness registry from agentcore.json, loads each harness.json to extract executionRoleArn and memory.name, passes HarnessRoleConfig[] to AgentCoreApplication so CDK creates the IAM role and wires memory grants. * feat: add ImperativeDeploymentManager framework * feat: add HarnessDeployer for post-CDK harness deployment * feat: wire HarnessDeployer into deploy pipeline Post-CDK phase runs after stack outputs are parsed. Harness deployer creates/updates/deletes harness resources via SigV4 API, then merges state into buildDeployedState. Teardown deletes harnesses before CDK stack destroy. * feat: log individual harness deploy operations to deploy log Wire onProgress callback from ImperativeDeployContext through to ExecLogger so each create/update/delete harness step appears in the deploy log file. * feat: show harness status in agentcore status command Adds harness to resource diffing in computeResourceStatuses and live status enrichment via getHarness API call. Adds harness icon to TUI ResourceGraph. * fix: address code review issues for harness deploy - Add console.warn when harness.json read fails in CDK app - Reuse specAny variable instead of duplicate cast - Add 1 MB file size limit for system prompt files - Throw when memory name not found in deployed state - Add comment explaining memory vs environmentArtifact update semantics * fix: allow harness-only projects in deploy validation Add hasHarnesses check to preflight validation so projects with only harnesses (no agents/memories/evaluators/gateways) can be deployed. Previously these were rejected with 'No resources defined' error. * fix: resolve memory ARN from CDK outputs for harness deployment On first deploy, deployed-state.json is empty so harnesses can't resolve memory ARNs from deployed state. Added fallback to resolve memory ARNs from CDK stack outputs when deployed state doesn't have them yet. Also pass cdkOutputs through to harness mapper from deployer context. * feat: support harness directory in app/ with auto-discovered system-prompt.md (#88) * feat: support harness directory in app/ with auto-discovered system-prompt.md Changes: 1. Harness paths now resolve relative to project root (like agent codeLocation) - Before: agentcore/harnesses/name (relative to agentcore/) - After: app/name (relative to project root) 2. Auto-discover system-prompt.md when systemPrompt field is undefined - If harness.json has no systemPrompt field, tries to load system-prompt.md - Falls back to undefined (no system prompt) if file doesn't exist Example structure: app/ chatbot/ harness.json system-prompt.md agentcore/ agentcore.json: "harnesses": [{"name": "chatbot", "path": "app/chatbot"}] * feat: resolve harness paths relative to project root and auto-load system-prompt.md - Changed harness path resolution from configRoot to projectRoot - Added tryLoadSystemPromptFile() to auto-discover system-prompt.md - When systemPrompt is undefined, attempts to load from system-prompt.md - Falls back to undefined if file doesn't exist * chore: remove plan files from PR * fix: address review feedback on harness deploy PR - Import shared toPascalId instead of duplicating in harness-mapper - Return undefined instead of empty object when memory has no arn/name - Use best-effort teardown so failed deployers don't orphan resources - Throw on harness.json parse failure in CDK bin instead of pushing partial config --- .../assets.snapshot.test.ts.snap | 30 +- src/assets/cdk/bin/cdk.ts | 21 + src/assets/cdk/lib/cdk-stack.ts | 9 +- src/cli/cloudformation/outputs.ts | 8 + src/cli/commands/deploy/actions.ts | 67 +- src/cli/commands/status/action.ts | 49 +- .../imperative/__tests__/manager.test.ts | 624 ++++++++++++++++++ .../__tests__/harness-deployer.test.ts | 416 ++++++++++++ .../__tests__/harness-mapper.test.ts | 443 +++++++++++++ .../imperative/deployers/harness-deployer.ts | 232 +++++++ .../imperative/deployers/harness-mapper.ts | 344 ++++++++++ .../deploy/imperative/deployers/index.ts | 2 + src/cli/operations/deploy/imperative/index.ts | 18 + .../operations/deploy/imperative/manager.ts | 110 +++ src/cli/operations/deploy/imperative/types.ts | 32 + src/cli/operations/deploy/preflight.ts | 7 +- src/cli/tui/components/ResourceGraph.tsx | 1 + 17 files changed, 2404 insertions(+), 9 deletions(-) create mode 100644 src/cli/operations/deploy/imperative/__tests__/manager.test.ts create mode 100644 src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts create mode 100644 src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts create mode 100644 src/cli/operations/deploy/imperative/deployers/harness-deployer.ts create mode 100644 src/cli/operations/deploy/imperative/deployers/harness-mapper.ts create mode 100644 src/cli/operations/deploy/imperative/deployers/index.ts create mode 100644 src/cli/operations/deploy/imperative/index.ts create mode 100644 src/cli/operations/deploy/imperative/manager.ts create mode 100644 src/cli/operations/deploy/imperative/types.ts diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 405d6e6e2..cba8ba9a3 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -99,6 +99,26 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } + // Read harness configs for role creation. + // Harness fields may not yet be on the AgentCoreProjectSpec type from @aws/agentcore-cdk, + // so we read them dynamically via specAny (same pattern as gateways above). + const harnessConfigs: { name: string; executionRoleArn?: string; memoryName?: string }[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessPath = path.resolve(configRoot, entry.path, 'harness.json'); + try { + const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + memoryName: harnessSpec.memory?.name, + }); + } catch (err) { + throw new Error( + \`Could not read harness.json for "\${entry.name}" at \${harnessPath}: \${err instanceof Error ? err.message : err}\` + ); + } + } + const app = new App(); for (const target of targets) { @@ -118,6 +138,7 @@ async function main() { spec, mcpSpec, credentials, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -278,6 +299,10 @@ export interface AgentCoreStackProps extends StackProps { * Credential provider ARNs from deployed state, keyed by credential name. */ credentials?: Record; + /** + * Harness role configurations. Each entry creates an IAM execution role for a harness. + */ + harnesses?: { name: string; executionRoleArn?: string; memoryName?: string }[]; } /** @@ -293,11 +318,12 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials } = props; + const { spec, mcpSpec, credentials, harnesses } = props; - // Create AgentCoreApplication with all agents + // Create AgentCoreApplication with all agents and harness roles this.application = new AgentCoreApplication(this, 'Application', { spec, + harnesses, }); // Create AgentCoreMcp if there are gateways configured diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 7a78b71cd..4f1b990d5 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -54,6 +54,26 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } + // Read harness configs for role creation. + // Harness fields may not yet be on the AgentCoreProjectSpec type from @aws/agentcore-cdk, + // so we read them dynamically via specAny (same pattern as gateways above). + const harnessConfigs: { name: string; executionRoleArn?: string; memoryName?: string }[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessPath = path.resolve(configRoot, entry.path, 'harness.json'); + try { + const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + memoryName: harnessSpec.memory?.name, + }); + } catch (err) { + throw new Error( + `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}` + ); + } + } + const app = new App(); for (const target of targets) { @@ -73,6 +93,7 @@ async function main() { spec, mcpSpec, credentials, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index a4d277821..46e94e0e0 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -20,6 +20,10 @@ export interface AgentCoreStackProps extends StackProps { * Credential provider ARNs from deployed state, keyed by credential name. */ credentials?: Record; + /** + * Harness role configurations. Each entry creates an IAM execution role for a harness. + */ + harnesses?: { name: string; executionRoleArn?: string; memoryName?: string }[]; } /** @@ -35,11 +39,12 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials } = props; + const { spec, mcpSpec, credentials, harnesses } = props; - // Create AgentCoreApplication with all agents + // Create AgentCoreApplication with all agents and harness roles this.application = new AgentCoreApplication(this, 'Application', { spec, + harnesses, }); // Create AgentCoreMcp if there are gateways configured diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 8151fcac8..cc7530138 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -2,6 +2,7 @@ import type { AgentCoreDeployedState, DeployedState, EvaluatorDeployedState, + HarnessDeployedState, MemoryDeployedState, OnlineEvalDeployedState, PolicyDeployedState, @@ -351,6 +352,7 @@ export interface BuildDeployedStateOptions { onlineEvalConfigs?: Record; policyEngines?: Record; policies?: Record; + harnesses?: Record; } /** @@ -370,6 +372,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta onlineEvalConfigs, policyEngines, policies, + harnesses, } = opts; const targetState: TargetDeployedState = { resources: { @@ -404,6 +407,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.onlineEvalConfigs = onlineEvalConfigs; } + // Add harness state if harnesses exist + if (harnesses && Object.keys(harnesses).length > 0) { + targetState.resources!.harnesses = harnesses; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 2ae8cf0e9..500eb6cda 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,5 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; -import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; +import type { AgentCoreMcpSpec, DeployedState, HarnessDeployedState } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { @@ -31,6 +31,7 @@ import { validateProject, } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; +import { createDeploymentManager } from '../../operations/deploy/imperative'; import type { DeployResult } from './types'; export interface ValidatedDeployOptions { @@ -336,6 +337,34 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ targets: {} }) as DeployedState); + const teardownContext = { + projectSpec: context.projectSpec, + target, + configIO, + deployedState: existingTeardownState, + onProgress: (step: string, status: 'start' | 'done' | 'error') => { + logger.log(`${step}: ${status}`); + }, + }; + + if (imperativeManager.hasDeployersForPhase('post-cdk', teardownContext)) { + startStep('Tear down imperative resources'); + const teardownResult = await imperativeManager.teardownAll(teardownContext); + if (!teardownResult.success) { + endStep('error', teardownResult.error); + logger.finalize(false); + return { + success: false, + error: `Imperative teardown failed: ${teardownResult.error}`, + logPath: logger.getRelativeLogPath(), + }; + } + endStep('success'); + } + // After deploying the empty spec, destroy the stack entirely startStep('Tear down stack'); const teardown = await performStackTeardown(target.name); @@ -408,7 +437,40 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); + // Post-CDK: deploy imperative resources (harness) + let deployedHarnesses: Record | undefined; + const imperativeManager = createDeploymentManager(); + const existingState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); + const imperativeContext = { + projectSpec: context.projectSpec, + target, + configIO, + deployedState: existingState, + cdkOutputs: outputs, + onProgress: (step: string, status: 'start' | 'done' | 'error') => { + logger.log(`${step}: ${status}`); + }, + }; + + if (imperativeManager.hasDeployersForPhase('post-cdk', imperativeContext)) { + startStep('Deploy harnesses'); + const postCdkResult = await imperativeManager.runPhase('post-cdk', imperativeContext); + if (!postCdkResult.success) { + endStep('error', postCdkResult.error); + logger.finalize(false); + return { + success: false, + error: `Harness deployment failed: ${postCdkResult.error}`, + logPath: logger.getRelativeLogPath(), + }; + } + const harnessResult = postCdkResult.results.get('harness'); + if (harnessResult?.state) { + deployedHarnesses = harnessResult.state as Record; + } + endStep('success'); + } + const deployedState = buildDeployedState({ targetName: target.name, stackName, @@ -422,6 +484,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise `${item.engineName}/${item.name}`, }); + const harnesses = diffResourceSet({ + resourceType: 'harness', + localItems: (project.harnesses ?? []).map(h => ({ name: h.name })), + deployedRecord: resources?.harnesses ?? {}, + getIdentifier: deployed => deployed.harnessArn, + }); + return [ ...agents, ...credentials, @@ -211,6 +220,7 @@ export function computeResourceStatuses( ...onlineEvalConfigs, ...policyEngines, ...policies, + ...harnesses, ]; } @@ -384,6 +394,43 @@ export async function handleProjectStatus( const hasOnlineEvalErrors = resources.some(r => r.resourceType === 'online-eval' && r.error); logger.endStep(hasOnlineEvalErrors ? 'error' : 'success'); } + + // Enrich deployed harnesses with live status + const harnessStates = targetResources?.harnesses ?? {}; + const deployedHarnesses = resources.filter( + e => e.resourceType === 'harness' && e.deploymentState === 'deployed' && harnessStates[e.name] + ); + + if (deployedHarnesses.length > 0) { + logger.startStep( + `Fetch harness status (${deployedHarnesses.length} harness${deployedHarnesses.length !== 1 ? 'es' : ''})` + ); + + await Promise.all( + resources.map(async (entry, i) => { + if (entry.resourceType !== 'harness' || entry.deploymentState !== 'deployed') return; + + const harnessState = harnessStates[entry.name]; + if (!harnessState) return; + + try { + const harnessResult = await getHarness({ + region: targetConfig.region, + harnessId: harnessState.harnessId, + }); + resources[i] = { ...entry, detail: harnessResult.harness.status }; + logger.log(` ${entry.name}: ${harnessResult.harness.status} (${harnessState.harnessId})`); + } catch (error) { + const errorMsg = getErrorMessage(error); + resources[i] = { ...entry, error: errorMsg }; + logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); + } + }) + ); + + const hasHarnessErrors = resources.some(r => r.resourceType === 'harness' && r.error); + logger.endStep(hasHarnessErrors ? 'error' : 'success'); + } } logger.finalize(true); diff --git a/src/cli/operations/deploy/imperative/__tests__/manager.test.ts b/src/cli/operations/deploy/imperative/__tests__/manager.test.ts new file mode 100644 index 000000000..f563f8fd5 --- /dev/null +++ b/src/cli/operations/deploy/imperative/__tests__/manager.test.ts @@ -0,0 +1,624 @@ +import { ImperativeDeploymentManager } from '../manager'; +import type { DeployPhase, DeployProgress, ImperativeDeployContext, ImperativeDeployer } from '../types'; +import { describe, expect, it, vi } from 'vitest'; + +function createMockDeployer( + overrides: Partial & { name: string; phase: DeployPhase } +): ImperativeDeployer { + return { + label: overrides.label ?? overrides.name, + shouldRun: overrides.shouldRun ?? (() => true), + deploy: overrides.deploy ?? (() => Promise.resolve({ success: true })), + teardown: overrides.teardown ?? (() => Promise.resolve({ success: true })), + ...overrides, + }; +} + +function createMockContext(overrides?: Partial): ImperativeDeployContext { + return { + projectSpec: {} as ImperativeDeployContext['projectSpec'], + target: {} as ImperativeDeployContext['target'], + configIO: {} as ImperativeDeployContext['configIO'], + deployedState: {} as ImperativeDeployContext['deployedState'], + ...overrides, + }; +} + +describe('ImperativeDeploymentManager', () => { + describe('register', () => { + it('returns this for chaining', () => { + const manager = new ImperativeDeploymentManager(); + const deployer = createMockDeployer({ name: 'a', phase: 'pre-cdk' }); + const result = manager.register(deployer); + expect(result).toBe(manager); + }); + }); + + describe('runPhase', () => { + it('runs deployers in registration order within a phase', async () => { + const order: string[] = []; + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'first', + phase: 'pre-cdk', + deploy: () => { + order.push('first'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'second', + phase: 'pre-cdk', + deploy: () => { + order.push('second'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'third', + phase: 'pre-cdk', + deploy: () => { + order.push('third'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(true); + expect(order).toEqual(['first', 'second', 'third']); + expect(result.results.size).toBe(3); + }); + + it('skips deployers where shouldRun returns false', async () => { + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'runs', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: true, state: { ran: true } }), + }) + ); + manager.register( + createMockDeployer({ + name: 'skipped', + phase: 'pre-cdk', + shouldRun: () => false, + deploy: () => Promise.resolve({ success: true, state: { ran: true } }), + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(true); + expect(result.results.has('runs')).toBe(true); + expect(result.results.has('skipped')).toBe(false); + }); + + it('stops on first failure (fail-fast)', async () => { + const order: string[] = []; + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'ok', + phase: 'post-cdk', + deploy: () => { + order.push('ok'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'fail', + phase: 'post-cdk', + deploy: () => { + order.push('fail'); + return Promise.resolve({ success: false, error: 'something broke' }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'never', + phase: 'post-cdk', + deploy: () => { + order.push('never'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('post-cdk', context); + + expect(result.success).toBe(false); + expect(result.error).toBe('something broke'); + expect(order).toEqual(['ok', 'fail']); + expect(result.results.has('never')).toBe(false); + }); + + it('handles thrown errors as failures', async () => { + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'thrower', + phase: 'pre-cdk', + deploy: () => Promise.reject(new Error('unexpected crash')), + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(false); + expect(result.error).toBe('unexpected crash'); + expect(result.results.get('thrower')?.success).toBe(false); + }); + + it('only runs deployers matching the requested phase', async () => { + const order: string[] = []; + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'pre', + phase: 'pre-cdk', + deploy: () => { + order.push('pre'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'post', + phase: 'post-cdk', + deploy: () => { + order.push('post'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'standalone', + phase: 'standalone', + deploy: () => { + order.push('standalone'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('post-cdk', context); + + expect(result.success).toBe(true); + expect(order).toEqual(['post']); + expect(result.results.size).toBe(1); + expect(result.results.has('post')).toBe(true); + }); + + it('calls progress callbacks correctly', async () => { + const manager = new ImperativeDeploymentManager(); + const onProgress = vi.fn(); + + manager.register( + createMockDeployer({ + name: 'deployer-a', + label: 'Deployer A', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: true }), + }) + ); + manager.register( + createMockDeployer({ + name: 'deployer-b', + label: 'Deployer B', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: false, error: 'oops' }), + }) + ); + + const context = createMockContext({ onProgress }); + await manager.runPhase('pre-cdk', context); + + expect(onProgress).toHaveBeenCalledTimes(4); + expect(onProgress).toHaveBeenNthCalledWith(1, 'Deployer A', 'start'); + expect(onProgress).toHaveBeenNthCalledWith(2, 'Deployer A', 'done'); + expect(onProgress).toHaveBeenNthCalledWith(3, 'Deployer B', 'start'); + expect(onProgress).toHaveBeenNthCalledWith(4, 'Deployer B', 'error'); + }); + + it('reports error status in progress callback on failure', async () => { + const manager = new ImperativeDeploymentManager(); + const onProgress = vi.fn(); + + manager.register( + createMockDeployer({ + name: 'fail', + label: 'Failing', + phase: 'standalone', + deploy: () => Promise.resolve({ success: false, error: 'boom' }), + }) + ); + + const context = createMockContext({ onProgress }); + await manager.runPhase('standalone', context); + + expect(onProgress).toHaveBeenCalledWith('Failing', 'start'); + expect(onProgress).toHaveBeenCalledWith('Failing', 'error'); + }); + + it('returns empty results when no deployers are registered', async () => { + const manager = new ImperativeDeploymentManager(); + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(true); + expect(result.results.size).toBe(0); + expect(result.notes).toEqual([]); + }); + + it('returns empty results when no deployers match the phase', async () => { + const manager = new ImperativeDeploymentManager(); + + manager.register(createMockDeployer({ name: 'post-only', phase: 'post-cdk' })); + + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(true); + expect(result.results.size).toBe(0); + }); + + it('collects notes from successful deployers', async () => { + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'noted', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: true, notes: ['note-1', 'note-2'] }), + }) + ); + manager.register( + createMockDeployer({ + name: 'also-noted', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: true, notes: ['note-3'] }), + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(true); + expect(result.notes).toEqual(['note-1', 'note-2', 'note-3']); + }); + + it('includes notes from deployers up to and including the failed one', async () => { + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'ok', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: true, notes: ['before-fail'] }), + }) + ); + manager.register( + createMockDeployer({ + name: 'fail', + phase: 'pre-cdk', + deploy: () => Promise.resolve({ success: false, error: 'failed', notes: ['fail-note'] }), + }) + ); + + const context = createMockContext(); + const result = await manager.runPhase('pre-cdk', context); + + expect(result.success).toBe(false); + expect(result.notes).toEqual(['before-fail', 'fail-note']); + }); + }); + + describe('teardownAll', () => { + it('runs deployers in reverse registration order', async () => { + const order: string[] = []; + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'first', + phase: 'pre-cdk', + teardown: () => { + order.push('first'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'second', + phase: 'post-cdk', + teardown: () => { + order.push('second'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'third', + phase: 'standalone', + teardown: () => { + order.push('third'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.teardownAll(context); + + expect(result.success).toBe(true); + expect(order).toEqual(['third', 'second', 'first']); + }); + + it('runs all deployers regardless of phase', async () => { + const manager = new ImperativeDeploymentManager(); + const torn: string[] = []; + + manager.register( + createMockDeployer({ + name: 'pre', + phase: 'pre-cdk', + teardown: () => { + torn.push('pre'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'post', + phase: 'post-cdk', + teardown: () => { + torn.push('post'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'standalone', + phase: 'standalone', + teardown: () => { + torn.push('standalone'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.teardownAll(context); + + expect(result.success).toBe(true); + expect(torn).toEqual(['standalone', 'post', 'pre']); + }); + + it('continues on failure and collects all errors (best-effort)', async () => { + const order: string[] = []; + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'first', + phase: 'pre-cdk', + teardown: () => { + order.push('first'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'second', + phase: 'post-cdk', + teardown: () => { + order.push('second'); + return Promise.resolve({ success: false, error: 'teardown failed' }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'third', + phase: 'standalone', + teardown: () => { + order.push('third'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.teardownAll(context); + + // Reverse order: third, second (fails), first still runs + expect(result.success).toBe(false); + expect(result.error).toBe('teardown failed'); + expect(order).toEqual(['third', 'second', 'first']); + }); + + it('collects multiple teardown errors', async () => { + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'first', + phase: 'pre-cdk', + teardown: () => Promise.resolve({ success: false, error: 'first broke' }), + }) + ); + manager.register( + createMockDeployer({ + name: 'second', + phase: 'post-cdk', + teardown: () => Promise.resolve({ success: false, error: 'second broke' }), + }) + ); + + const context = createMockContext(); + const result = await manager.teardownAll(context); + + expect(result.success).toBe(false); + expect(result.error).toBe('second broke; first broke'); + }); + + it('skips deployers where shouldRun returns false', async () => { + const manager = new ImperativeDeploymentManager(); + const torn: string[] = []; + + manager.register( + createMockDeployer({ + name: 'active', + phase: 'pre-cdk', + teardown: () => { + torn.push('active'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'inactive', + phase: 'post-cdk', + shouldRun: () => false, + teardown: () => { + torn.push('inactive'); + return Promise.resolve({ success: true }); + }, + }) + ); + + const context = createMockContext(); + const result = await manager.teardownAll(context); + + expect(result.success).toBe(true); + expect(torn).toEqual(['active']); + }); + + it('calls progress callbacks correctly during teardown', async () => { + const manager = new ImperativeDeploymentManager(); + const onProgress = vi.fn(); + + manager.register( + createMockDeployer({ + name: 'td', + label: 'Teardown Step', + phase: 'pre-cdk', + teardown: () => Promise.resolve({ success: true }), + }) + ); + + const context = createMockContext({ onProgress }); + await manager.teardownAll(context); + + expect(onProgress).toHaveBeenCalledWith('Teardown Step', 'start'); + expect(onProgress).toHaveBeenCalledWith('Teardown Step', 'done'); + }); + + it('handles thrown errors during teardown and continues', async () => { + const order: string[] = []; + const manager = new ImperativeDeploymentManager(); + + manager.register( + createMockDeployer({ + name: 'ok', + phase: 'pre-cdk', + teardown: () => { + order.push('ok'); + return Promise.resolve({ success: true }); + }, + }) + ); + manager.register( + createMockDeployer({ + name: 'thrower', + phase: 'post-cdk', + teardown: () => Promise.reject(new Error('teardown crash')), + }) + ); + + const context = createMockContext(); + const result = await manager.teardownAll(context); + + expect(result.success).toBe(false); + expect(result.error).toBe('teardown crash'); + expect(order).toEqual(['ok']); + }); + }); + + describe('hasDeployersForPhase', () => { + it('returns true when a deployer matches phase and shouldRun', () => { + const manager = new ImperativeDeploymentManager(); + manager.register(createMockDeployer({ name: 'a', phase: 'pre-cdk' })); + + const context = createMockContext(); + expect(manager.hasDeployersForPhase('pre-cdk', context)).toBe(true); + }); + + it('returns false when no deployers match the phase', () => { + const manager = new ImperativeDeploymentManager(); + manager.register(createMockDeployer({ name: 'a', phase: 'post-cdk' })); + + const context = createMockContext(); + expect(manager.hasDeployersForPhase('pre-cdk', context)).toBe(false); + }); + + it('returns false when deployer matches phase but shouldRun returns false', () => { + const manager = new ImperativeDeploymentManager(); + manager.register( + createMockDeployer({ + name: 'a', + phase: 'pre-cdk', + shouldRun: () => false, + }) + ); + + const context = createMockContext(); + expect(manager.hasDeployersForPhase('pre-cdk', context)).toBe(false); + }); + + it('returns false when no deployers are registered', () => { + const manager = new ImperativeDeploymentManager(); + const context = createMockContext(); + expect(manager.hasDeployersForPhase('standalone', context)).toBe(false); + }); + + it('returns true when at least one deployer matches among many', () => { + const manager = new ImperativeDeploymentManager(); + manager.register(createMockDeployer({ name: 'a', phase: 'pre-cdk', shouldRun: () => false })); + manager.register(createMockDeployer({ name: 'b', phase: 'post-cdk' })); + manager.register(createMockDeployer({ name: 'c', phase: 'pre-cdk', shouldRun: () => true })); + + const context = createMockContext(); + expect(manager.hasDeployersForPhase('pre-cdk', context)).toBe(true); + }); + }); +}); diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts new file mode 100644 index 000000000..7adff05ed --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts @@ -0,0 +1,416 @@ +import type { ConfigIO } from '../../../../../../lib'; +import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../../../../schema'; +import * as harnessApi from '../../../../../aws/agentcore-harness'; +import type { ImperativeDeployContext } from '../../types'; +import { HarnessDeployer } from '../harness-deployer'; +import * as harnessMapper from '../harness-mapper'; +import { readFile } from 'fs/promises'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), +})); + +vi.mock('../../../../../aws/agentcore-harness', () => ({ + createHarness: vi.fn(), + updateHarness: vi.fn(), + deleteHarness: vi.fn(), +})); + +vi.mock('../harness-mapper', () => ({ + mapHarnessSpecToCreateOptions: vi.fn(), +})); + +const mockedReadFile = vi.mocked(readFile); +const mockedCreateHarness = vi.mocked(harnessApi.createHarness); +const mockedUpdateHarness = vi.mocked(harnessApi.updateHarness); +const mockedDeleteHarness = vi.mocked(harnessApi.deleteHarness); +const mockedMapHarness = vi.mocked(harnessMapper.mapHarnessSpecToCreateOptions); + +const REGION = 'us-east-1'; +const TARGET_NAME = 'default'; +const CONFIG_ROOT = '/project/agentcore'; + +function createContext(overrides?: { + harnesses?: AgentCoreProjectSpec['harnesses']; + deployedHarnesses?: DeployedState['targets'][string]['resources']; + cdkOutputs?: Record; +}): ImperativeDeployContext { + const projectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + harnesses: overrides?.harnesses, + } as AgentCoreProjectSpec; + + const target: AwsDeploymentTarget = { + name: TARGET_NAME, + account: '123456789012', + region: REGION, + }; + + const deployedState: DeployedState = { + targets: { + [TARGET_NAME]: { + resources: overrides?.deployedHarnesses ?? {}, + }, + }, + }; + + const configIO = { + getConfigRoot: () => CONFIG_ROOT, + getPathResolver: () => ({ getBaseDir: () => CONFIG_ROOT }), + } as unknown as ConfigIO; + + return { + projectSpec, + target, + deployedState, + configIO, + cdkOutputs: overrides?.cdkOutputs ?? {}, + }; +} + +const HARNESS_SPEC_JSON = JSON.stringify({ + name: 'my_harness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' }, + tools: [], + skills: [], +}); + +describe('HarnessDeployer', () => { + let deployer: HarnessDeployer; + + beforeEach(() => { + vi.clearAllMocks(); + deployer = new HarnessDeployer(); + }); + + describe('metadata', () => { + it('has correct name, label, and phase', () => { + expect(deployer.name).toBe('harness'); + expect(deployer.label).toBe('Harnesses'); + expect(deployer.phase).toBe('post-cdk'); + }); + }); + + describe('shouldRun', () => { + it('returns true when project has harnesses', () => { + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + }); + expect(deployer.shouldRun(ctx)).toBe(true); + }); + + it('returns true when only deployed state has harnesses', () => { + const ctx = createContext({ + deployedHarnesses: { + harnesses: { + old_harness: { + harnessId: 'h-123', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + }, + }, + }, + }); + expect(deployer.shouldRun(ctx)).toBe(true); + }); + + it('returns false when no harnesses anywhere', () => { + const ctx = createContext(); + expect(deployer.shouldRun(ctx)).toBe(false); + }); + + it('returns false when harnesses array is empty', () => { + const ctx = createContext({ harnesses: [] }); + expect(deployer.shouldRun(ctx)).toBe(false); + }); + }); + + describe('deploy', () => { + it('creates a harness when not previously deployed', async () => { + const createOptions = { + region: REGION, + harnessName: 'my_harness', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + model: { bedrockModelConfig: { modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' } }, + }; + + mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_JSON); + mockedMapHarness.mockResolvedValueOnce(createOptions); + mockedCreateHarness.mockResolvedValueOnce({ + harness: { + harnessId: 'h-new', + harnessName: 'my_harness', + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-new', + status: 'CREATING', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }); + + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + cdkOutputs: { + ApplicationHarnessMyHarnessRoleArnOutput123: 'arn:aws:iam::123456789012:role/HarnessRole', + }, + }); + + const result = await deployer.deploy(ctx); + + expect(result.success).toBe(true); + expect(result.state).toEqual({ + my_harness: { + harnessId: 'h-new', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-new', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'CREATING', + memoryArn: undefined, + }, + }); + expect(mockedCreateHarness).toHaveBeenCalledWith(createOptions); + expect(result.notes).toContain('Created harness "my_harness"'); + }); + + it('updates a harness when already deployed', async () => { + const createOptions = { + region: REGION, + harnessName: 'my_harness', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + model: { bedrockModelConfig: { modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' } }, + }; + + mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_JSON); + mockedMapHarness.mockResolvedValueOnce(createOptions); + mockedUpdateHarness.mockResolvedValueOnce({ + harness: { + harnessId: 'h-existing', + harnessName: 'my_harness', + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-existing', + status: 'UPDATING', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, + }); + + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + deployedHarnesses: { + harnesses: { + my_harness: { + harnessId: 'h-existing', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-existing', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + }, + }, + }, + cdkOutputs: { + ApplicationHarnessMyHarnessRoleArnOutput123: 'arn:aws:iam::123456789012:role/HarnessRole', + }, + }); + + const result = await deployer.deploy(ctx); + + expect(result.success).toBe(true); + expect(mockedUpdateHarness).toHaveBeenCalledWith( + expect.objectContaining({ + region: REGION, + harnessId: 'h-existing', + }) + ); + expect(result.notes).toContain('Updated harness "my_harness"'); + }); + + it('deletes a harness removed from project spec', async () => { + mockedDeleteHarness.mockResolvedValueOnce({ + harness: { + harnessId: 'h-old', + harnessName: 'old_harness', + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-old', + status: 'DELETING', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, + }); + + const ctx = createContext({ + harnesses: [], + deployedHarnesses: { + harnesses: { + old_harness: { + harnessId: 'h-old', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-old', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + }, + }, + }, + }); + + const result = await deployer.deploy(ctx); + + expect(result.success).toBe(true); + expect(mockedDeleteHarness).toHaveBeenCalledWith({ region: REGION, harnessId: 'h-old' }); + expect(result.notes).toContain('Deleted harness "old_harness"'); + // Deleted harness should not appear in result state + expect(result.state).toEqual({}); + }); + + it('returns error when role ARN not found in CDK outputs', async () => { + mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_JSON); + + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + cdkOutputs: { + // No matching output key + SomeOtherOutput: 'value', + }, + }); + + const result = await deployer.deploy(ctx); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not find role ARN'); + expect(result.error).toContain('my_harness'); + }); + + it('returns error when harness.json cannot be read', async () => { + mockedReadFile.mockRejectedValueOnce(new Error('ENOENT: no such file or directory')); + + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + cdkOutputs: { + ApplicationHarnessMyHarnessRoleArnOutput123: 'arn:aws:iam::123456789012:role/HarnessRole', + }, + }); + + const result = await deployer.deploy(ctx); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to read harness.json'); + }); + + it('returns error when API call fails', async () => { + mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_JSON); + mockedMapHarness.mockResolvedValueOnce({ + region: REGION, + harnessName: 'my_harness', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + }); + mockedCreateHarness.mockRejectedValueOnce(new Error('Service unavailable')); + + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + cdkOutputs: { + ApplicationHarnessMyHarnessRoleArnOutput123: 'arn:aws:iam::123456789012:role/HarnessRole', + }, + }); + + const result = await deployer.deploy(ctx); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to deploy harness "my_harness"'); + expect(result.error).toContain('Service unavailable'); + }); + }); + + describe('teardown', () => { + it('deletes all deployed harnesses', async () => { + mockedDeleteHarness + .mockResolvedValueOnce({ + harness: { + harnessId: 'h-1', + harnessName: 'harness_a', + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-1', + status: 'DELETING', + executionRoleArn: 'arn:aws:iam::123456789012:role/RoleA', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + .mockResolvedValueOnce({ + harness: { + harnessId: 'h-2', + harnessName: 'harness_b', + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-2', + status: 'DELETING', + executionRoleArn: 'arn:aws:iam::123456789012:role/RoleB', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }); + + const ctx = createContext({ + deployedHarnesses: { + harnesses: { + harness_a: { + harnessId: 'h-1', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-1', + roleArn: 'arn:aws:iam::123456789012:role/RoleA', + status: 'READY', + }, + harness_b: { + harnessId: 'h-2', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-2', + roleArn: 'arn:aws:iam::123456789012:role/RoleB', + status: 'READY', + }, + }, + }, + }); + + const result = await deployer.teardown(ctx); + + expect(result.success).toBe(true); + expect(result.state).toEqual({}); + expect(mockedDeleteHarness).toHaveBeenCalledTimes(2); + expect(result.notes).toContain('Deleted harness "harness_a"'); + expect(result.notes).toContain('Deleted harness "harness_b"'); + }); + + it('returns error when delete fails during teardown', async () => { + mockedDeleteHarness.mockRejectedValueOnce(new Error('Access denied')); + + const ctx = createContext({ + deployedHarnesses: { + harnesses: { + bad_harness: { + harnessId: 'h-bad', + harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-bad', + roleArn: 'arn:aws:iam::123456789012:role/Role', + status: 'READY', + }, + }, + }, + }); + + const result = await deployer.teardown(ctx); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to delete harness "bad_harness"'); + }); + + it('succeeds with empty state when no harnesses are deployed', async () => { + const ctx = createContext(); + + const result = await deployer.teardown(ctx); + + expect(result.success).toBe(true); + expect(result.state).toEqual({}); + expect(mockedDeleteHarness).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts new file mode 100644 index 000000000..737eb1f74 --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts @@ -0,0 +1,443 @@ +import type { DeployedResourceState, HarnessSpec } from '../../../../../../schema'; +import { mapHarnessSpecToCreateOptions } from '../harness-mapper'; +import { readFile, stat } from 'fs/promises'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + stat: vi.fn(), +})); + +const mockedReadFile = vi.mocked(readFile); +const mockedStat = vi.mocked(stat); + +beforeEach(() => { + vi.clearAllMocks(); + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + mockedStat.mockRejectedValue(enoent); +}); + +function minimalSpec(overrides?: Partial): HarnessSpec { + return { + name: 'test_harness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' }, + tools: [], + skills: [], + ...overrides, + }; +} + +const BASE_OPTIONS = { + harnessDir: '/project/agentcore/harnesses/test_harness', + executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + region: 'us-east-1' as const, +}; + +describe('mapHarnessSpecToCreateOptions', () => { + // ── Model mapping ────────────────────────────────────────────────────── + + describe('model mapping', () => { + it('maps bedrock provider with temperature, topP, and maxTokens', async () => { + const spec = minimalSpec({ + model: { + provider: 'bedrock', + modelId: 'anthropic.claude-3-sonnet-20240229-v1:0', + temperature: 0.7, + topP: 0.9, + maxTokens: 4096, + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.model).toEqual({ + bedrockModelConfig: { + modelId: 'anthropic.claude-3-sonnet-20240229-v1:0', + temperature: 0.7, + topP: 0.9, + maxTokens: 4096, + }, + }); + }); + + it('maps open_ai provider with apiKeyArn to apiKeyCredentialProviderArn', async () => { + const spec = minimalSpec({ + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + temperature: 0.5, + topP: 0.8, + maxTokens: 2048, + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.model).toEqual({ + openAIModelConfig: { + modelId: 'gpt-4o', + apiKeyCredentialProviderArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + temperature: 0.5, + topP: 0.8, + maxTokens: 2048, + }, + }); + }); + + it('maps gemini provider with topK', async () => { + const spec = minimalSpec({ + model: { + provider: 'gemini', + modelId: 'gemini-1.5-pro', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:gemini-key', + topK: 0.4, + temperature: 0.3, + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.model).toEqual({ + geminiModelConfig: { + modelId: 'gemini-1.5-pro', + apiKeyCredentialProviderArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:gemini-key', + topK: 0.4, + temperature: 0.3, + }, + }); + }); + }); + + // ── System prompt mapping ────────────────────────────────────────────── + + describe('system prompt mapping', () => { + it('reads system prompt from a file path starting with ./', async () => { + mockedStat.mockResolvedValueOnce({ size: 100 } as any); + mockedReadFile.mockResolvedValueOnce('You are a helpful assistant.'); + + const spec = minimalSpec({ systemPrompt: './prompt.md' }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(mockedReadFile).toHaveBeenCalledWith(join(BASE_OPTIONS.harnessDir, './prompt.md'), 'utf-8'); + expect(result.systemPrompt).toEqual([{ text: 'You are a helpful assistant.' }]); + }); + + it('reads system prompt from a file path starting with ../', async () => { + mockedStat.mockResolvedValueOnce({ size: 100 } as any); + mockedReadFile.mockResolvedValueOnce('Shared prompt content.'); + + const spec = minimalSpec({ systemPrompt: '../shared/prompt.txt' }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(mockedReadFile).toHaveBeenCalledWith(join(BASE_OPTIONS.harnessDir, '../shared/prompt.txt'), 'utf-8'); + expect(result.systemPrompt).toEqual([{ text: 'Shared prompt content.' }]); + }); + + it('rejects system prompt file exceeding 1 MB', async () => { + mockedStat.mockResolvedValueOnce({ size: 1024 * 1024 + 1 } as any); + + const spec = minimalSpec({ systemPrompt: './huge-prompt.md' }); + + await expect(mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec })).rejects.toThrow('too large'); + }); + + it('treats non-path strings as literal text', async () => { + const spec = minimalSpec({ systemPrompt: 'You are a research assistant.' }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(mockedReadFile).not.toHaveBeenCalled(); + expect(result.systemPrompt).toEqual([{ text: 'You are a research assistant.' }]); + }); + + it('omits system prompt when not specified', async () => { + const spec = minimalSpec(); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.systemPrompt).toBeUndefined(); + }); + }); + + // ── Tools mapping ────────────────────────────────────────────────────── + + describe('tools mapping', () => { + it('passes tools through with type, name, and config', async () => { + const spec = minimalSpec({ + tools: [ + { + type: 'remote_mcp', + name: 'my_mcp_tool', + config: { remoteMcp: { url: 'https://mcp.example.com' } }, + }, + { + type: 'agentcore_browser', + name: 'browser_tool', + }, + ], + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.tools).toEqual([ + { + type: 'remote_mcp', + name: 'my_mcp_tool', + config: { remoteMcp: { url: 'https://mcp.example.com' } }, + }, + { + type: 'agentcore_browser', + name: 'browser_tool', + }, + ]); + }); + + it('omits tools when array is empty', async () => { + const spec = minimalSpec({ tools: [] }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.tools).toBeUndefined(); + }); + }); + + // ── Skills mapping ───────────────────────────────────────────────────── + + describe('skills mapping', () => { + it('maps string array to { path } objects', async () => { + const spec = minimalSpec({ skills: ['research', 'summarize'] }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.skills).toEqual([{ path: 'research' }, { path: 'summarize' }]); + }); + + it('omits skills when array is empty', async () => { + const spec = minimalSpec({ skills: [] }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.skills).toBeUndefined(); + }); + }); + + // ── Memory mapping ───────────────────────────────────────────────────── + + describe('memory mapping', () => { + it('resolves memory by name from deployed state', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + + const spec = minimalSpec({ memory: { name: 'my_memory' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + }); + + expect(result.memory).toEqual({ + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }); + }); + + it('uses ARN directly when provided', async () => { + const spec = minimalSpec({ + memory: { arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/custom-mem' }, + }); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.memory).toEqual({ + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/custom-mem', + }); + }); + + it('throws when memory name is not found in deployed state', async () => { + const spec = minimalSpec({ memory: { name: 'nonexistent' } }); + + await expect(mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec })).rejects.toThrow( + 'Memory "nonexistent" referenced by harness is not in deployed state' + ); + }); + }); + + // ── Truncation mapping ───────────────────────────────────────────────── + + describe('truncation mapping', () => { + it('passes through truncation configuration', async () => { + const spec = minimalSpec({ + truncation: { + strategy: 'sliding_window', + config: { slidingWindow: { messagesCount: 20 } }, + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.truncation).toEqual({ + strategy: 'sliding_window', + config: { slidingWindow: { messagesCount: 20 } }, + }); + }); + }); + + // ── Container URI mapping ────────────────────────────────────────────── + + describe('container URI mapping', () => { + it('maps containerUri to environmentArtifact', async () => { + const spec = minimalSpec({ + containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-harness:latest', + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.environmentArtifact).toEqual({ + containerConfiguration: { + containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-harness:latest', + }, + }); + }); + }); + + // ── Network/Lifecycle mapping ────────────────────────────────────────── + + describe('environment provider mapping', () => { + it('maps networkConfig to environment.agentCoreRuntimeEnvironment.networkConfiguration', async () => { + const spec = minimalSpec({ + networkMode: 'VPC', + networkConfig: { + subnets: ['subnet-12345678'], + securityGroups: ['sg-12345678'], + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.environment).toEqual({ + agentCoreRuntimeEnvironment: { + networkConfiguration: { + subnetIds: ['subnet-12345678'], + securityGroupIds: ['sg-12345678'], + }, + }, + }); + }); + + it('maps lifecycleConfig to environment.agentCoreRuntimeEnvironment.lifecycleConfiguration', async () => { + const spec = minimalSpec({ + lifecycleConfig: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28800, + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.environment).toEqual({ + agentCoreRuntimeEnvironment: { + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28800, + }, + }, + }); + }); + + it('combines network and lifecycle in the same environment provider', async () => { + const spec = minimalSpec({ + networkMode: 'VPC', + networkConfig: { + subnets: ['subnet-12345678'], + securityGroups: ['sg-12345678'], + }, + lifecycleConfig: { + idleRuntimeSessionTimeout: 600, + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.environment).toEqual({ + agentCoreRuntimeEnvironment: { + networkConfiguration: { + subnetIds: ['subnet-12345678'], + securityGroupIds: ['sg-12345678'], + }, + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 600, + }, + }, + }); + }); + + it('omits environment when neither network nor lifecycle is present', async () => { + const spec = minimalSpec(); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.environment).toBeUndefined(); + }); + }); + + // ── Pass-through fields ──────────────────────────────────────────────── + + describe('pass-through fields', () => { + it('includes execution limits', async () => { + const spec = minimalSpec({ + maxIterations: 10, + maxTokens: 8192, + timeoutSeconds: 300, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.maxIterations).toBe(10); + expect(result.maxTokens).toBe(8192); + expect(result.timeoutSeconds).toBe(300); + }); + + it('includes environment variables', async () => { + const spec = minimalSpec({ + environmentVariables: { API_KEY: 'secret', DEBUG: 'true' }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.environmentVariables).toEqual({ API_KEY: 'secret', DEBUG: 'true' }); + }); + + it('includes tags', async () => { + const spec = minimalSpec({ + tags: { team: 'platform', env: 'prod' }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.tags).toEqual({ team: 'platform', env: 'prod' }); + }); + + it('includes allowedTools', async () => { + const spec = minimalSpec({ + allowedTools: ['*', 'my_tool'], + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.allowedTools).toEqual(['*', 'my_tool']); + }); + }); + + // ── Core fields ──────────────────────────────────────────────────────── + + describe('core fields', () => { + it('sets region, harnessName, and executionRoleArn', async () => { + const spec = minimalSpec(); + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.region).toBe('us-east-1'); + expect(result.harnessName).toBe('test_harness'); + expect(result.executionRoleArn).toBe('arn:aws:iam::123456789012:role/HarnessRole'); + }); + }); +}); diff --git a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts new file mode 100644 index 000000000..b410483ba --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts @@ -0,0 +1,232 @@ +/** + * HarnessDeployer - Post-CDK imperative deployer for Harness resources. + * + * Runs after CDK deploy to create, update, or delete harness resources + * via the SigV4 API client. Harness role ARNs are resolved from CDK + * stack outputs, and harness specs are read from disk (harness.json). + */ +import type { HarnessDeployedState, HarnessSpec } from '../../../../../schema'; +import { HarnessSpecSchema } from '../../../../../schema'; +import type { CreateHarnessResult, UpdateHarnessOptions, UpdateHarnessResult } from '../../../../aws/agentcore-harness'; +import { createHarness, deleteHarness, updateHarness } from '../../../../aws/agentcore-harness'; +import { toPascalId } from '../../../../cloudformation/logical-ids'; +import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from '../types'; +import { mapHarnessSpecToCreateOptions } from './harness-mapper'; +import { readFile } from 'fs/promises'; +import { dirname, join } from 'path'; + +// ============================================================================ +// Types +// ============================================================================ + +type HarnessDeployedStateMap = Record; + +// ============================================================================ +// Deployer +// ============================================================================ + +export class HarnessDeployer implements ImperativeDeployer { + readonly name = 'harness'; + readonly label = 'Harnesses'; + readonly phase: DeployPhase = 'post-cdk'; + + shouldRun(context: ImperativeDeployContext): boolean { + const projectHarnesses = context.projectSpec.harnesses; + const hasProjectHarnesses = !!projectHarnesses && projectHarnesses.length > 0; + + const targetName = context.target.name; + const deployedHarnesses = context.deployedState.targets?.[targetName]?.resources?.harnesses; + const hasDeployedHarnesses = !!deployedHarnesses && Object.keys(deployedHarnesses).length > 0; + + return hasProjectHarnesses || hasDeployedHarnesses; + } + + async deploy(context: ImperativeDeployContext): Promise> { + const { projectSpec, target, configIO, deployedState, cdkOutputs } = context; + const region = target.region; + const targetName = target.name; + const configRoot = configIO.getConfigRoot(); + const projectRoot = dirname(configRoot); + + const projectHarnesses = projectSpec.harnesses ?? []; + const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {}; + const resultState: HarnessDeployedStateMap = {}; + const notes: string[] = []; + + // Build set of harness names in current project spec + const projectHarnessNames = new Set(projectHarnesses.map(h => h.name)); + + // Create or update each harness in the project spec + for (const entry of projectHarnesses) { + // Harness path is relative to project root (like agent codeLocation) + const harnessDir = join(projectRoot, entry.path); + + // Read harness.json from disk and validate + let harnessSpec: HarnessSpec; + try { + const raw = await readFile(join(harnessDir, 'harness.json'), 'utf-8'); + const parsed: unknown = JSON.parse(raw); + const validated = HarnessSpecSchema.safeParse(parsed); + if (!validated.success) { + return { + success: false, + error: `Invalid harness.json for "${entry.name}": ${validated.error.message}`, + }; + } + harnessSpec = validated.data; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to read harness.json for "${entry.name}": ${message}` }; + } + + // Resolve role ARN from CDK outputs + const roleArn = resolveRoleArn(entry.name, cdkOutputs); + if (!roleArn) { + return { + success: false, + error: `Could not find role ARN in CDK outputs for harness "${entry.name}". Expected output key starting with "ApplicationHarness${toPascalId(entry.name)}RoleArn".`, + }; + } + + // Use executionRoleArn from harness spec if provided, otherwise use CDK output + const executionRoleArn = harnessSpec.executionRoleArn ?? roleArn; + + const deployedResources = deployedState.targets?.[targetName]?.resources; + const existingHarness = deployedHarnesses[entry.name]; + + try { + if (existingHarness) { + // Update existing harness + const createOptions = await mapHarnessSpecToCreateOptions({ + harnessSpec, + harnessDir, + executionRoleArn, + region, + deployedResources, + cdkOutputs, + }); + + // Memory uses { optionalValue: null } to explicitly clear it when removed from config, + // since the API treats an absent field as "no change" but null as "remove". + // environmentArtifact uses undefined (omit) because container config is immutable + // after creation — it cannot be cleared via update, only set on create. + const updateOptions: UpdateHarnessOptions = { + region, + harnessId: existingHarness.harnessId, + executionRoleArn: createOptions.executionRoleArn, + model: createOptions.model, + systemPrompt: createOptions.systemPrompt, + tools: createOptions.tools, + skills: createOptions.skills, + allowedTools: createOptions.allowedTools, + memory: createOptions.memory ? { optionalValue: createOptions.memory } : { optionalValue: null }, + truncation: createOptions.truncation, + maxIterations: createOptions.maxIterations, + maxTokens: createOptions.maxTokens, + timeoutSeconds: createOptions.timeoutSeconds, + environment: createOptions.environment, + environmentArtifact: createOptions.environmentArtifact + ? { optionalValue: createOptions.environmentArtifact } + : undefined, + environmentVariables: createOptions.environmentVariables, + tags: createOptions.tags, + }; + + const updateResult: UpdateHarnessResult = await updateHarness(updateOptions); + resultState[entry.name] = { + harnessId: updateResult.harness.harnessId, + harnessArn: updateResult.harness.arn, + roleArn: executionRoleArn, + status: updateResult.harness.status, + memoryArn: createOptions.memory?.memoryArn, + }; + notes.push(`Updated harness "${entry.name}"`); + } else { + // Create new harness + const createOptions = await mapHarnessSpecToCreateOptions({ + harnessSpec, + harnessDir, + executionRoleArn, + region, + deployedResources, + cdkOutputs, + }); + + const createResult: CreateHarnessResult = await createHarness(createOptions); + resultState[entry.name] = { + harnessId: createResult.harness.harnessId, + harnessArn: createResult.harness.arn, + roleArn: executionRoleArn, + status: createResult.harness.status, + memoryArn: createOptions.memory?.memoryArn, + }; + notes.push(`Created harness "${entry.name}"`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to deploy harness "${entry.name}": ${message}` }; + } + } + + // Delete harnesses that exist in deployed state but not in project spec + for (const [name, state] of Object.entries(deployedHarnesses)) { + if (!projectHarnessNames.has(name)) { + try { + await deleteHarness({ region, harnessId: state.harnessId }); + notes.push(`Deleted harness "${name}"`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to delete harness "${name}": ${message}` }; + } + } + } + + return { success: true, state: resultState, notes }; + } + + async teardown(context: ImperativeDeployContext): Promise> { + const { target, deployedState } = context; + const region = target.region; + const targetName = target.name; + + const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {}; + const notes: string[] = []; + + for (const [name, state] of Object.entries(deployedHarnesses)) { + try { + await deleteHarness({ region, harnessId: state.harnessId }); + notes.push(`Deleted harness "${name}"`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to delete harness "${name}": ${message}` }; + } + } + + return { success: true, state: {}, notes }; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Resolve the IAM role ARN for a harness from CDK stack outputs. + * + * The CDK construct exports the role ARN with a key matching the pattern: + * ApplicationHarness{PascalName}RoleArn... + */ +function resolveRoleArn(harnessName: string, cdkOutputs?: Record): string | undefined { + if (!cdkOutputs) return undefined; + + const pascalName = toPascalId(harnessName); + const prefix = `ApplicationHarness${pascalName}RoleArn`; + + for (const [key, value] of Object.entries(cdkOutputs)) { + if (key.startsWith(prefix)) { + return value; + } + } + + return undefined; +} diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts new file mode 100644 index 000000000..86abbf0ab --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts @@ -0,0 +1,344 @@ +/** + * Maps user-facing HarnessSpec (harness.json) to the CreateHarness API wire format. + * + * Each transformation is a pure function that converts a section of the spec + * into the corresponding API field. The top-level mapHarnessSpecToCreateOptions + * orchestrates them and returns a complete CreateHarnessOptions object. + */ +import type { DeployedResourceState, HarnessSpec } from '../../../../../schema'; +import type { + CreateHarnessOptions, + HarnessEnvironmentArtifact, + HarnessEnvironmentProvider, + HarnessMemoryConfiguration, + HarnessModelConfiguration, + HarnessSkill, + HarnessSystemPrompt, + HarnessTool, + HarnessTruncationConfiguration, +} from '../../../../aws/agentcore-harness'; +import { toPascalId } from '../../../../cloudformation/logical-ids'; +import { readFile, stat } from 'fs/promises'; +import { join } from 'path'; + +const MAX_PROMPT_FILE_SIZE = 1024 * 1024; // 1 MB + +// ============================================================================ +// Public Interface +// ============================================================================ + +export interface MapHarnessOptions { + harnessSpec: HarnessSpec; + harnessDir: string; + executionRoleArn: string; + region: string; + deployedResources?: DeployedResourceState; + cdkOutputs?: Record; +} + +/** + * Transform a HarnessSpec into CreateHarnessOptions for the control plane API. + */ +export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): Promise { + const { harnessSpec, harnessDir, executionRoleArn, region, deployedResources, cdkOutputs } = options; + + const result: CreateHarnessOptions = { + region, + harnessName: harnessSpec.name, + executionRoleArn, + }; + + // Model + result.model = mapModel(harnessSpec.model); + + // System prompt (may read from disk or auto-discover system-prompt.md) + if (harnessSpec.systemPrompt !== undefined) { + result.systemPrompt = await mapSystemPrompt(harnessSpec.systemPrompt, harnessDir); + } else { + // Auto-discover system-prompt.md if it exists + result.systemPrompt = await tryLoadSystemPromptFile(harnessDir); + } + + // Tools + if (harnessSpec.tools.length > 0) { + result.tools = mapTools(harnessSpec.tools); + } + + // Skills + if (harnessSpec.skills.length > 0) { + result.skills = mapSkills(harnessSpec.skills); + } + + // Allowed tools + if (harnessSpec.allowedTools) { + result.allowedTools = harnessSpec.allowedTools; + } + + // Memory + if (harnessSpec.memory) { + result.memory = mapMemory(harnessSpec.memory, deployedResources, cdkOutputs); + } + + // Truncation + if (harnessSpec.truncation) { + result.truncation = mapTruncation(harnessSpec.truncation); + } + + // Execution limits + if (harnessSpec.maxIterations !== undefined) { + result.maxIterations = harnessSpec.maxIterations; + } + if (harnessSpec.maxTokens !== undefined) { + result.maxTokens = harnessSpec.maxTokens; + } + if (harnessSpec.timeoutSeconds !== undefined) { + result.timeoutSeconds = harnessSpec.timeoutSeconds; + } + + // Container artifact + if (harnessSpec.containerUri) { + result.environmentArtifact = mapEnvironmentArtifact(harnessSpec.containerUri); + } + + // Environment provider (network + lifecycle) + const environmentProvider = mapEnvironmentProvider(harnessSpec); + if (environmentProvider) { + result.environment = environmentProvider; + } + + // Environment variables + if (harnessSpec.environmentVariables) { + result.environmentVariables = harnessSpec.environmentVariables; + } + + // Tags + if (harnessSpec.tags) { + result.tags = harnessSpec.tags; + } + + return result; +} + +// ============================================================================ +// Model Mapping +// ============================================================================ + +function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { + const { provider, modelId, apiKeyArn, temperature, topP, topK, maxTokens } = model; + + switch (provider) { + case 'bedrock': + return { + bedrockModelConfig: { + modelId, + ...(temperature !== undefined && { temperature }), + ...(topP !== undefined && { topP }), + ...(maxTokens !== undefined && { maxTokens }), + }, + }; + case 'open_ai': + return { + openAIModelConfig: { + modelId, + ...(apiKeyArn && { apiKeyCredentialProviderArn: apiKeyArn }), + ...(temperature !== undefined && { temperature }), + ...(topP !== undefined && { topP }), + ...(maxTokens !== undefined && { maxTokens }), + }, + }; + case 'gemini': + return { + geminiModelConfig: { + modelId, + ...(apiKeyArn && { apiKeyCredentialProviderArn: apiKeyArn }), + ...(temperature !== undefined && { temperature }), + ...(topP !== undefined && { topP }), + ...(topK !== undefined && { topK }), + ...(maxTokens !== undefined && { maxTokens }), + }, + }; + } +} + +// ============================================================================ +// System Prompt Mapping +// ============================================================================ + +const FILE_PATH_PATTERN = /^\.\.?\//; +const FILE_EXTENSION_PATTERN = /\.(md|txt)$/; + +function isFilePath(value: string): boolean { + return FILE_PATH_PATTERN.test(value) || FILE_EXTENSION_PATTERN.test(value); +} + +async function mapSystemPrompt(prompt: string, harnessDir: string): Promise { + let text: string; + + if (isFilePath(prompt)) { + const filePath = join(harnessDir, prompt); + const fileStats = await stat(filePath); + if (fileStats.size > MAX_PROMPT_FILE_SIZE) { + throw new Error( + `System prompt file "${prompt}" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.` + ); + } + text = await readFile(filePath, 'utf-8'); + } else { + text = prompt; + } + + return [{ text }]; +} + +/** + * Try to load system-prompt.md from harness directory. + * Returns undefined if file doesn't exist (harness will have no system prompt). + */ +async function tryLoadSystemPromptFile(harnessDir: string): Promise { + const promptPath = join(harnessDir, 'system-prompt.md'); + + try { + const fileStats = await stat(promptPath); + if (fileStats.size > MAX_PROMPT_FILE_SIZE) { + throw new Error( + `System prompt file "system-prompt.md" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.` + ); + } + const text = await readFile(promptPath, 'utf-8'); + return [{ text }]; + } catch (err) { + // File doesn't exist - return undefined (no system prompt) + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + // Other errors (permissions, etc.) should be thrown + throw err; + } +} + +// ============================================================================ +// Tools Mapping +// ============================================================================ + +function mapTools(tools: HarnessSpec['tools']): HarnessTool[] { + return tools.map(tool => ({ + type: tool.type, + name: tool.name, + ...(tool.config && { config: tool.config as unknown as Record }), + })); +} + +// ============================================================================ +// Skills Mapping +// ============================================================================ + +function mapSkills(skills: string[]): HarnessSkill[] { + return skills.map(path => ({ path })); +} + +// ============================================================================ +// Memory Mapping +// ============================================================================ + +function mapMemory( + memory: NonNullable, + deployedResources?: DeployedResourceState, + cdkOutputs?: Record +): HarnessMemoryConfiguration | undefined { + // Direct ARN takes precedence + if (memory.arn) { + return { memoryArn: memory.arn }; + } + + // Resolve by name from deployed state or CDK outputs + if (memory.name) { + // Try deployed state first + const deployedMemory = deployedResources?.memories?.[memory.name]; + if (deployedMemory) { + return { memoryArn: deployedMemory.memoryArn }; + } + + // Fall back to CDK outputs + if (cdkOutputs) { + const memoryArn = resolveMemoryArnFromOutputs(memory.name, cdkOutputs); + if (memoryArn) { + return { memoryArn }; + } + } + + throw new Error( + `Memory "${memory.name}" referenced by harness is not in deployed state. Ensure the memory is defined in agentcore.json and has been deployed.` + ); + } + + return undefined; +} + +/** + * Resolve memory ARN from CDK stack outputs. + * The CDK construct exports memory ARNs with keys matching: + * ApplicationMemory{PascalName}ArnOutput... + */ +function resolveMemoryArnFromOutputs(memoryName: string, cdkOutputs: Record): string | undefined { + const pascalName = toPascalId(memoryName); + const prefix = `ApplicationMemory${pascalName}ArnOutput`; + + for (const [key, value] of Object.entries(cdkOutputs)) { + if (key.startsWith(prefix)) { + return value; + } + } + + return undefined; +} + +// ============================================================================ +// Truncation Mapping +// ============================================================================ + +function mapTruncation(truncation: NonNullable): HarnessTruncationConfiguration { + return { + strategy: truncation.strategy, + config: truncation.config as HarnessTruncationConfiguration['config'], + }; +} + +// ============================================================================ +// Container / Environment Artifact Mapping +// ============================================================================ + +function mapEnvironmentArtifact(containerUri: string): HarnessEnvironmentArtifact { + return { + containerConfiguration: { containerUri }, + }; +} + +// ============================================================================ +// Environment Provider (Network + Lifecycle) Mapping +// ============================================================================ + +function mapEnvironmentProvider(spec: HarnessSpec): HarnessEnvironmentProvider | undefined { + const hasNetwork = !!spec.networkConfig; + const hasLifecycle = !!spec.lifecycleConfig; + + if (!hasNetwork && !hasLifecycle) { + return undefined; + } + + const agentCoreRuntimeEnvironment: Record = {}; + + if (spec.networkConfig) { + agentCoreRuntimeEnvironment.networkConfiguration = { + subnetIds: spec.networkConfig.subnets, + securityGroupIds: spec.networkConfig.securityGroups, + }; + } + + if (spec.lifecycleConfig) { + agentCoreRuntimeEnvironment.lifecycleConfiguration = spec.lifecycleConfig; + } + + return { + agentCoreRuntimeEnvironment, + }; +} diff --git a/src/cli/operations/deploy/imperative/deployers/index.ts b/src/cli/operations/deploy/imperative/deployers/index.ts new file mode 100644 index 000000000..655785b10 --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/index.ts @@ -0,0 +1,2 @@ +export { HarnessDeployer } from './harness-deployer'; +export { mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './harness-mapper'; diff --git a/src/cli/operations/deploy/imperative/index.ts b/src/cli/operations/deploy/imperative/index.ts new file mode 100644 index 000000000..930dfe094 --- /dev/null +++ b/src/cli/operations/deploy/imperative/index.ts @@ -0,0 +1,18 @@ +import { HarnessDeployer } from './deployers'; +import { ImperativeDeploymentManager } from './manager'; + +export type { + DeployPhase, + DeployProgress, + ImperativeDeployContext, + ImperativeDeployResult, + ImperativeDeployer, +} from './types'; + +export { ImperativeDeploymentManager, type ImperativePhaseResult } from './manager'; + +export { HarnessDeployer, mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './deployers'; + +export function createDeploymentManager(): ImperativeDeploymentManager { + return new ImperativeDeploymentManager().register(new HarnessDeployer()); +} diff --git a/src/cli/operations/deploy/imperative/manager.ts b/src/cli/operations/deploy/imperative/manager.ts new file mode 100644 index 000000000..b7e22ecda --- /dev/null +++ b/src/cli/operations/deploy/imperative/manager.ts @@ -0,0 +1,110 @@ +import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from './types'; + +export interface ImperativePhaseResult { + success: boolean; + results: Map; + error?: string; + notes: string[]; +} + +export class ImperativeDeploymentManager { + private readonly deployers: ImperativeDeployer[] = []; + + register(deployer: ImperativeDeployer): this { + this.deployers.push(deployer); + return this; + } + + async runPhase(phase: DeployPhase, context: ImperativeDeployContext): Promise { + const results = new Map(); + const notes: string[] = []; + + const applicable = this.deployers.filter(d => d.phase === phase && d.shouldRun(context)); + + for (const deployer of applicable) { + context.onProgress?.(deployer.label, 'start'); + + try { + const result = await deployer.deploy(context); + results.set(deployer.name, result); + + if (result.notes) { + notes.push(...result.notes); + } + + if (!result.success) { + context.onProgress?.(deployer.label, 'error'); + return { + success: false, + results, + error: result.error ?? `Deployer '${deployer.name}' failed`, + notes, + }; + } + + context.onProgress?.(deployer.label, 'done'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + results.set(deployer.name, { success: false, error: errorMessage }); + context.onProgress?.(deployer.label, 'error'); + return { + success: false, + results, + error: errorMessage, + notes, + }; + } + } + + return { success: true, results, notes }; + } + + async teardownAll(context: ImperativeDeployContext): Promise { + const results = new Map(); + const notes: string[] = []; + const errors: string[] = []; + + const applicable = this.deployers.filter(d => d.shouldRun(context)).reverse(); + + for (const deployer of applicable) { + context.onProgress?.(deployer.label, 'start'); + + try { + const result = await deployer.teardown(context); + results.set(deployer.name, result); + + if (result.notes) { + notes.push(...result.notes); + } + + if (!result.success) { + context.onProgress?.(deployer.label, 'error'); + errors.push(result.error ?? `Teardown of '${deployer.name}' failed`); + continue; + } + + context.onProgress?.(deployer.label, 'done'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + results.set(deployer.name, { success: false, error: errorMessage }); + context.onProgress?.(deployer.label, 'error'); + errors.push(errorMessage); + } + } + + if (errors.length > 0) { + return { + success: false, + results, + error: errors.join('; '), + notes, + }; + } + + return { success: true, results, notes }; + } + + hasDeployersForPhase(phase: DeployPhase, context: ImperativeDeployContext): boolean { + return this.deployers.some(d => d.phase === phase && d.shouldRun(context)); + } +} diff --git a/src/cli/operations/deploy/imperative/types.ts b/src/cli/operations/deploy/imperative/types.ts new file mode 100644 index 000000000..7efa13e7a --- /dev/null +++ b/src/cli/operations/deploy/imperative/types.ts @@ -0,0 +1,32 @@ +import type { ConfigIO } from '../../../../lib'; +import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../../schema'; + +export type DeployPhase = 'pre-cdk' | 'post-cdk' | 'standalone'; + +export type DeployProgress = (step: string, status: 'start' | 'done' | 'error') => void; + +export interface ImperativeDeployContext { + projectSpec: AgentCoreProjectSpec; + target: AwsDeploymentTarget; + configIO: ConfigIO; + deployedState: DeployedState; + onProgress?: DeployProgress; + cdkOutputs?: Record; + autoConfirm?: boolean; +} + +export interface ImperativeDeployResult> { + success: boolean; + state?: TState; + notes?: string[]; + error?: string; +} + +export interface ImperativeDeployer> { + readonly name: string; + readonly label: string; + readonly phase: DeployPhase; + shouldRun(context: ImperativeDeployContext): boolean; + deploy(context: ImperativeDeployContext): Promise>; + teardown(context: ImperativeDeployContext): Promise>; +} diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 4aa24e71b..119a52252 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -88,7 +88,10 @@ export async function validateProject(): Promise { // Check for gateways in agentcore.json const hasGateways = projectSpec.agentCoreGateways && projectSpec.agentCoreGateways.length > 0; - if (!hasAgents && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines) { + // Check for harnesses in agentcore.json + const hasHarnesses = projectSpec.harnesses && projectSpec.harnesses.length > 0; + + if (!hasAgents && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines && !hasHarnesses) { let hasExistingStack = false; try { const deployedState = await configIO.readDeployedState(); @@ -98,7 +101,7 @@ export async function validateProject(): Promise { } if (!hasExistingStack) { throw new Error( - 'No resources defined in project. Add at least one resource (agent, memory, evaluator, or gateway) before deploying.' + 'No resources defined in project. Add at least one resource (agent, memory, evaluator, gateway, or harness) before deploying.' ); } isTeardownDeploy = true; diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index eacb9bd10..1d8d276c0 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -20,6 +20,7 @@ const ICONS = { 'online-eval': '↻', 'policy-engine': '▣', policy: '▢', + harness: '⊞', } as const; interface ResourceGraphProps { From e77dc2d83c84a43672323f24a50eef7951304bb7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:42:37 -0400 Subject: [PATCH 10/49] feat: harness create/add with deploy and bug fixes (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ConfigIO and PathResolver support for harness files Add HARNESS_DIR constant and three new PathResolver methods for resolving harness directory paths. Add readHarnessSpec and writeHarnessSpec methods to ConfigIO for reading and writing harness.json files. Includes comprehensive test coverage following TDD approach. * feat: add harness to ResourceType union * feat(harness): add HarnessPrimitive class with add/remove operations Implement HarnessPrimitive that manages harness lifecycle: - Add harness config + registry entry + auto-memory - Remove harness + directory cleanup - CLI commands for add/remove - Full test coverage (15 tests, all passing) Key features: - Auto-creates memory entry (name: Memory) - Writes harness.json and system-prompt.md - Supports all harness config options (model, network, lifecycle, etc.) - Registers `agentcore add harness` and `agentcore remove harness` commands - Wired into primitives registry and ALL_PRIMITIVES array Type exports: - Export HarnessModelProvider from schema - Add 'harness' to RemoveLogger resourceType union - Add 'harness' to RemoveFlow initialResourceType union * feat: add harness validation and action for create command Implements harness-specific validation and action functions for the create command: - harness-validate.ts: Validates CLI options for harness creation - Validates harness name with HarnessNameSchema - Checks directory doesn't already exist - Normalizes model provider (accepts both 'bedrock'/'Bedrock', etc.) - Requires api-key-arn for non-bedrock providers - Defaults to bedrock provider with Claude Sonnet 4.5 model - harness-action.ts: Creates project with harness - Calls createProject() to scaffold base project - Calls harnessPrimitive.add() to add harness configuration - Reports progress via onProgress callback - Comprehensive test coverage for both validation and action functions * feat: fork create command between harness (default) and agent paths Restructures the create command to make harness creation the default path, with agent/runtime creation only triggered when agent-specific flags are present. Key changes: - Add harness-specific flags to CreateOptions: modelId, apiKeyArn, harnessMemory, maxIterations, maxTokens, timeout, truncationStrategy - Add flag detection logic: AGENT_PATH_FLAGS triggers agent path, HARNESS_ONLY_FLAGS for harness-only options - Implement handleCreateHarnessCLI for harness creation with validation and progress output - Rename handleCreateCLI to handleCreateAgentCLI to clarify its purpose - Add printCreateHarnessSummary for harness-specific completion output - Update action handler to fork between paths with conflict detection - Add new CLI flags: --model-id, --api-key-arn, --no-harness-memory, --max-iterations, --max-tokens, --timeout, --truncation-strategy - Update --defaults behavior: only fills agent defaults when on agent path - Update --framework description to indicate it triggers agent/runtime path - Update --defaults description to remove Python/Strands mention (now path-agnostic) Behavior: - agentcore create --name X → harness path (default) - agentcore create --name X --framework Strands → agent path - agentcore create --name X --no-agent → bare project - agentcore create --name X --framework Strands --model-id foo → error (conflict) - agentcore create --name X --defaults → harness with defaults - agentcore create --name X --defaults --framework Strands → agent with agent defaults * feat: add TUI harness wizard screens Create the AddHarnessScreen wizard component shared between create and add flows, plus the flow wrapper, state hook, types, and barrel export. Files created: - src/cli/tui/screens/harness/types.ts - src/cli/tui/screens/harness/useAddHarnessWizard.ts - src/cli/tui/screens/harness/AddHarnessScreen.tsx - src/cli/tui/screens/harness/AddHarnessFlow.tsx - src/cli/tui/screens/harness/index.ts The wizard follows the exact pattern from AddMemoryScreen/AddMemoryFlow: - Custom hook manages wizard state and step sequence - Dynamic step sequence based on provider (bedrock skips api-key-arn) and advanced selections - TextInput with validation, WizardSelect, WizardMultiSelect components - ConfirmReview for final confirmation - Flow handles loading existing names, calling harnessPrimitive.add(), and showing success/error * feat: replace create prompt with three-option harness/agent/skip selector * feat: add harness as first option in TUI add resource picker * fix: update existing tests for harness-first behavior - preflight.test.ts: update error message to include harness - create.test.ts: add --framework flag to tests that need agent path - integ tests: adjust navigation offsets for Harness as first Add option * fix: persist deployed-state on harness API failure and fix TUI advanced config deployed-state.json is now written with CDK outputs (memories, roles, stack name) even when the post-CDK harness API call fails. Previously both the CLI and TUI deploy paths returned early, losing all CDK state. The TUI advanced config wizard had a stale closure bug: selecting advanced settings and pressing Enter jumped straight to Confirm because allSteps was computed from the previous empty state. Fix computes the first advanced step directly from the incoming settings array. * fix: address PR review - stale closure in setNetworkMode and region validation - Fix setNetworkMode stale closure: compute next step directly when VPC is selected instead of relying on allSteps (same pattern as setAdvancedSettings) - Validate detected region against AgentCoreRegionSchema in ensureDefaultTarget with a clear error listing supported regions * fix: remove implicit deploy-on-create for harness projects Users should explicitly run `agentcore deploy` after create. This also eliminates the process.chdir() concern from code review. --- integ-tests/tui/add-gateway-jwt.test.ts | 6 +- integ-tests/tui/add-memory-episodic.test.ts | 3 +- integ-tests/tui/lifecycle-config.test.ts | 3 +- .../commands/create/__tests__/create.test.ts | 6 +- .../create/__tests__/harness-action.test.ts | 106 +++++ .../create/__tests__/harness-validate.test.ts | 155 ++++++++ src/cli/commands/create/command.tsx | 195 ++++++++- src/cli/commands/create/harness-action.ts | 92 +++++ src/cli/commands/create/harness-validate.ts | 80 ++++ src/cli/commands/create/types.ts | 8 + src/cli/commands/deploy/actions.ts | 28 +- src/cli/commands/remove/types.ts | 1 + src/cli/logging/remove-logger.ts | 1 + .../deploy/__tests__/preflight.test.ts | 2 +- src/cli/primitives/HarnessPrimitive.ts | 337 ++++++++++++++++ .../__tests__/HarnessPrimitive.test.ts | 327 ++++++++++++++++ src/cli/primitives/index.ts | 3 + src/cli/primitives/registry.ts | 3 + src/cli/tui/screens/add/AddFlow.tsx | 17 + src/cli/tui/screens/add/AddScreen.tsx | 1 + src/cli/tui/screens/create/CreateScreen.tsx | 99 ++++- src/cli/tui/screens/create/useCreateFlow.ts | 120 ++++-- src/cli/tui/screens/deploy/useDeployFlow.ts | 61 +++ .../tui/screens/harness/AddHarnessFlow.tsx | 116 ++++++ .../tui/screens/harness/AddHarnessScreen.tsx | 369 ++++++++++++++++++ src/cli/tui/screens/harness/index.ts | 3 + src/cli/tui/screens/harness/types.ts | 84 ++++ .../screens/harness/useAddHarnessWizard.ts | 258 ++++++++++++ src/cli/tui/screens/remove/RemoveFlow.tsx | 1 + src/lib/constants.ts | 3 + .../schemas/io/__tests__/config-io.test.ts | 71 ++++ .../io/__tests__/path-resolver.test.ts | 15 + src/lib/schemas/io/config-io.ts | 19 +- src/lib/schemas/io/path-resolver.ts | 23 +- src/schema/schemas/agentcore-project.ts | 2 +- 35 files changed, 2534 insertions(+), 84 deletions(-) create mode 100644 src/cli/commands/create/__tests__/harness-action.test.ts create mode 100644 src/cli/commands/create/__tests__/harness-validate.test.ts create mode 100644 src/cli/commands/create/harness-action.ts create mode 100644 src/cli/commands/create/harness-validate.ts create mode 100644 src/cli/primitives/HarnessPrimitive.ts create mode 100644 src/cli/primitives/__tests__/HarnessPrimitive.test.ts create mode 100644 src/cli/tui/screens/harness/AddHarnessFlow.tsx create mode 100644 src/cli/tui/screens/harness/AddHarnessScreen.tsx create mode 100644 src/cli/tui/screens/harness/index.ts create mode 100644 src/cli/tui/screens/harness/types.ts create mode 100644 src/cli/tui/screens/harness/useAddHarnessWizard.ts diff --git a/integ-tests/tui/add-gateway-jwt.test.ts b/integ-tests/tui/add-gateway-jwt.test.ts index e18ea5e22..15e1e7284 100644 --- a/integ-tests/tui/add-gateway-jwt.test.ts +++ b/integ-tests/tui/add-gateway-jwt.test.ts @@ -133,9 +133,9 @@ describe('Add Gateway JWT Flow', () => { it('Step 1c: navigates to Gateway and enters the wizard', async () => { // Add Resource list order: - // 0: Agent, 1: Memory, 2: Identity, 3: Evaluator, - // 4: Online Eval Config, 5: Gateway, 6: Gateway Target - for (let i = 0; i < 5; i++) { + // 0: Harness, 1: Agent, 2: Memory, 3: Credential, 4: Evaluator, + // 5: Online Eval Config, 6: Gateway, 7: Gateway Target, 8: Policy + for (let i = 0; i < 6; i++) { await session.sendSpecialKey('down'); } await settle(); diff --git a/integ-tests/tui/add-memory-episodic.test.ts b/integ-tests/tui/add-memory-episodic.test.ts index c4dd65d46..c2caad335 100644 --- a/integ-tests/tui/add-memory-episodic.test.ts +++ b/integ-tests/tui/add-memory-episodic.test.ts @@ -94,7 +94,8 @@ describe('Add Memory with EPISODIC Strategy', () => { }); it('Step 3: selects Memory from the resource list', async () => { - // Add Resource list: 0: Agent, 1: Memory + // Add Resource list: 0: Harness, 1: Agent, 2: Memory + await session.sendSpecialKey('down'); await session.sendSpecialKey('down'); await settle(); diff --git a/integ-tests/tui/lifecycle-config.test.ts b/integ-tests/tui/lifecycle-config.test.ts index c8cc5947b..12e69e03c 100644 --- a/integ-tests/tui/lifecycle-config.test.ts +++ b/integ-tests/tui/lifecycle-config.test.ts @@ -252,7 +252,8 @@ describe('Add Agent BYO Flow: Lifecycle Configuration via TUI', () => { expect(atAdd).toBe(true); saveTextScreenshot(session, 'byo-02-add-resource'); - // Select Agent (first option) + // Select Agent (second option, after Harness) + await session.sendSpecialKey('down'); await session.sendSpecialKey('enter'); const atAgent = await safeWaitFor(session, /agent|Name/i, 5_000); diff --git a/src/cli/commands/create/__tests__/create.test.ts b/src/cli/commands/create/__tests__/create.test.ts index 972b38533..2051d1398 100644 --- a/src/cli/commands/create/__tests__/create.test.ts +++ b/src/cli/commands/create/__tests__/create.test.ts @@ -70,7 +70,8 @@ describe('create command', () => { }); it('requires all options without --no-agent', async () => { - const result = await runCLI(['create', '--name', 'Incomplete', '--json'], testDir); + // --framework triggers the agent path, which requires --language, --model-provider, etc. + const result = await runCLI(['create', '--name', 'Incomplete', '--framework', 'Strands', '--json'], testDir); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); @@ -161,7 +162,8 @@ describe('create command', () => { describe('--dry-run', () => { it('shows files without creating', async () => { const name = `DryRun${Date.now()}`; - const result = await runCLI(['create', '--name', name, '--defaults', '--dry-run'], testDir); + // --framework triggers agent path where --dry-run is supported + const result = await runCLI(['create', '--name', name, '--defaults', '--framework', 'Strands', '--dry-run'], testDir); expect(result.exitCode).toBe(0); expect(result.stdout.includes('would create') || result.stdout.includes('Dry run')).toBeTruthy(); diff --git a/src/cli/commands/create/__tests__/harness-action.test.ts b/src/cli/commands/create/__tests__/harness-action.test.ts new file mode 100644 index 000000000..18f00c306 --- /dev/null +++ b/src/cli/commands/create/__tests__/harness-action.test.ts @@ -0,0 +1,106 @@ +import { createProjectWithHarness } from '../harness-action.js'; +import { exists } from '../../../../test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('createProjectWithHarness', () => { + let testDir: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `harness-action-${randomUUID()}`); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('creates project with harness', async () => { + const name = `TestH${randomUUID().slice(0, 6)}`; + const result = await createProjectWithHarness({ + name, + cwd: testDir, + modelProvider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + skipGit: true, + skipInstall: true, + }); + + expect(result.success, `Error: ${result.error}`).toBe(true); + expect(result.projectPath).toBeTruthy(); + + const projectPath = result.projectPath!; + const configDir = join(projectPath, 'agentcore'); + const harnessDir = join(configDir, 'harnesses', name); + + await expect(exists(projectPath)).resolves.toBe(true); + await expect(exists(configDir)).resolves.toBe(true); + await expect(exists(harnessDir)).resolves.toBe(true); + await expect(exists(join(harnessDir, 'harness.json'))).resolves.toBe(true); + await expect(exists(join(harnessDir, 'system-prompt.md'))).resolves.toBe(true); + }); + + it('creates harness with custom options', async () => { + const name = `CustomH${randomUUID().slice(0, 6)}`; + const result = await createProjectWithHarness({ + name, + cwd: testDir, + modelProvider: 'open_ai', + modelId: 'gpt-4', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + skipMemory: true, + maxIterations: 10, + maxTokens: 2000, + timeoutSeconds: 300, + truncationStrategy: 'sliding_window', + networkMode: 'PUBLIC', + skipGit: true, + skipInstall: true, + }); + + expect(result.success, `Error: ${result.error}`).toBe(true); + expect(result.projectPath).toBeTruthy(); + + const harnessJsonPath = join(result.projectPath!, 'agentcore', 'harnesses', name, 'harness.json'); + await expect(exists(harnessJsonPath)).resolves.toBe(true); + }); + + it('reports progress during creation', async () => { + const name = `ProgH${randomUUID().slice(0, 6)}`; + const progressSteps: string[] = []; + + const result = await createProjectWithHarness({ + name, + cwd: testDir, + modelProvider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + skipGit: true, + skipInstall: true, + onProgress: (step, status) => { + if (status === 'done') { + progressSteps.push(step); + } + }, + }); + + expect(result.success, `Error: ${result.error}`).toBe(true); + expect(progressSteps).toContain('Add harness to project'); + }); + + it('handles errors gracefully', async () => { + const name = '!!!invalid-name!!!'; + const result = await createProjectWithHarness({ + name, + cwd: testDir, + modelProvider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + skipGit: true, + skipInstall: true, + }); + + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/src/cli/commands/create/__tests__/harness-validate.test.ts b/src/cli/commands/create/__tests__/harness-validate.test.ts new file mode 100644 index 000000000..b77ccae5f --- /dev/null +++ b/src/cli/commands/create/__tests__/harness-validate.test.ts @@ -0,0 +1,155 @@ +import { validateCreateHarnessOptions } from '../harness-validate.js'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('validateCreateHarnessOptions', () => { + let testDir: string; + + beforeAll(() => { + testDir = join(tmpdir(), `harness-create-validate-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'existingHarness'), { recursive: true }); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('requires name', () => { + const result = validateCreateHarnessOptions({}, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('--name'); + }); + + it('rejects invalid harness name starting with digit', () => { + const result = validateCreateHarnessOptions({ name: '1invalid' }, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('letter'); + }); + + it('rejects invalid harness name with special characters', () => { + const result = validateCreateHarnessOptions({ name: 'invalid-name!' }, testDir); + expect(result.valid).toBe(false); + }); + + it('rejects existing directory', () => { + const result = validateCreateHarnessOptions({ name: 'existingHarness' }, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('already exists'); + }); + + it('accepts valid bedrock options with defaults', () => { + const result = validateCreateHarnessOptions({ name: 'myHarness' }, testDir); + expect(result.valid).toBe(true); + }); + + it('accepts explicit model provider and id', () => { + const result = validateCreateHarnessOptions( + { + name: 'myHarness2', + modelProvider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }, + testDir + ); + expect(result.valid).toBe(true); + }); + + it('requires api-key-arn for non-bedrock providers', () => { + const result = validateCreateHarnessOptions( + { + name: 'myHarness3', + modelProvider: 'open_ai', + modelId: 'gpt-4', + }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('--api-key-arn'); + }); + + it('accepts non-bedrock provider with api-key-arn', () => { + const result = validateCreateHarnessOptions( + { + name: 'myHarness4', + modelProvider: 'open_ai', + modelId: 'gpt-4', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + }, + testDir + ); + expect(result.valid).toBe(true); + }); + + it('normalizes titlecase model provider to lowercase', () => { + const options: any = { + name: 'myHarness5', + modelProvider: 'Bedrock', + modelId: 'test-model', + }; + const result = validateCreateHarnessOptions(options, testDir); + expect(result.valid).toBe(true); + expect(options.modelProvider).toBe('bedrock'); + }); + + it('normalizes OpenAI to open_ai', () => { + const options: any = { + name: 'myHarness6', + modelProvider: 'OpenAI', + modelId: 'gpt-4', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + }; + const result = validateCreateHarnessOptions(options, testDir); + expect(result.valid).toBe(true); + expect(options.modelProvider).toBe('open_ai'); + }); + + it('normalizes Gemini to gemini', () => { + const options: any = { + name: 'myHarness7', + modelProvider: 'Gemini', + modelId: 'gemini-pro', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + }; + const result = validateCreateHarnessOptions(options, testDir); + expect(result.valid).toBe(true); + expect(options.modelProvider).toBe('gemini'); + }); + + it('rejects invalid model provider', () => { + const result = validateCreateHarnessOptions( + { + name: 'myHarness8', + modelProvider: 'azure', + modelId: 'test-model', + }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('provider'); + }); + + it('applies default model provider and id', () => { + const options: any = { name: 'myHarness9' }; + const result = validateCreateHarnessOptions(options, testDir); + expect(result.valid).toBe(true); + expect(options.modelProvider).toBe('bedrock'); + expect(options.modelId).toBe('us.anthropic.claude-sonnet-4-5-20250514-v1:0'); + }); + + it('accepts valid harness name with underscores', () => { + const result = validateCreateHarnessOptions({ name: 'my_valid_harness_123' }, testDir); + expect(result.valid).toBe(true); + }); + + it('rejects harness name longer than 48 characters', () => { + const result = validateCreateHarnessOptions( + { name: 'a'.repeat(49) }, + testDir + ); + expect(result.valid).toBe(false); + }); +}); diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 7d7c3ff75..97b30cded 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -1,6 +1,7 @@ import { getWorkingDirectory } from '../../../lib'; import type { BuildType, + HarnessModelProvider, ModelProvider, NetworkMode, ProtocolMode, @@ -13,11 +14,29 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { CreateScreen } from '../../tui/screens/create'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; +import { createProjectWithHarness } from './harness-action'; +import { normalizeHarnessModelProvider, validateCreateHarnessOptions } from './harness-validate'; import type { CreateOptions } from './types'; import { validateCreateOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; +/** Flags that trigger the agent/runtime path */ +const AGENT_PATH_FLAGS = ['framework', 'language', 'build', 'protocol', 'type', 'agentId', 'agentAliasId'] as const; + +/** Flags that are harness-only */ +const HARNESS_ONLY_FLAGS = ['modelId', 'apiKeyArn', 'maxIterations', 'maxTokens', 'timeout', 'truncationStrategy'] as const; + +/** Determines if the agent path should be taken based on provided flags */ +function isAgentPath(options: CreateOptions): boolean { + return AGENT_PATH_FLAGS.some(flag => options[flag] !== undefined); +} + +/** Determines if any harness-only flags are present */ +function hasHarnessOnlyFlags(options: CreateOptions): boolean { + return HARNESS_ONLY_FLAGS.some(flag => options[flag] !== undefined); +} + /** Render CreateScreen for interactive TUI mode */ function handleCreateTUI(): void { const cwd = getWorkingDirectory(); @@ -72,8 +91,96 @@ function printCreateSummary( console.log(''); } -/** Handle CLI mode with progress output */ -async function handleCreateCLI(options: CreateOptions): Promise { +/** Print completion summary after successful harness create */ +function printCreateHarnessSummary(projectName: string): void { + const green = '\x1b[32m'; + const cyan = '\x1b[36m'; + const dim = '\x1b[2m'; + const reset = '\x1b[0m'; + + console.log(''); + + // Created summary + console.log(`${dim}Created:${reset}`); + console.log(` ${projectName}/`); + console.log(` agentcore/ ${dim}Config and CDK project${reset}`); + console.log(` agentcore/harnesses/${projectName}/ ${dim}Harness config${reset}`); + console.log(''); + + // Success and next steps + console.log(`${green}Harness project created successfully!${reset}`); + console.log(''); + console.log('To continue:'); + console.log(` ${cyan}cd ${projectName}${reset}`); + console.log(` ${cyan}agentcore deploy${reset}`); + console.log(''); +} + +/** Handle CLI mode for the harness path */ +async function handleCreateHarnessCLI(options: CreateOptions): Promise { + const cwd = options.outputDir ?? getWorkingDirectory(); + + const validation = validateCreateHarnessOptions( + { name: options.name, modelProvider: options.modelProvider, modelId: options.modelId, apiKeyArn: options.apiKeyArn }, + cwd + ); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + // Progress callback + const green = '\x1b[32m'; + const reset = '\x1b[0m'; + const onProgress: ProgressCallback | undefined = options.json + ? undefined + : (step, status) => { + if (status === 'done') console.log(`${green}[done]${reset} ${step}`); + else if (status === 'error') console.log(`\x1b[31m[error]${reset} ${step}`); + }; + + const provider = (options.modelProvider + ? normalizeHarnessModelProvider(options.modelProvider) + : 'bedrock') as HarnessModelProvider; + const modelId = options.modelId ?? 'us.anthropic.claude-sonnet-4-5-20250514-v1:0'; + + const result = await createProjectWithHarness({ + name: options.name!, + cwd, + modelProvider: provider, + modelId, + apiKeyArn: options.apiKeyArn, + skipMemory: options.harnessMemory === false, + maxIterations: options.maxIterations ? Number(options.maxIterations) : undefined, + maxTokens: options.maxTokens ? Number(options.maxTokens) : undefined, + timeoutSeconds: options.timeout ? Number(options.timeout) : undefined, + truncationStrategy: options.truncationStrategy as 'sliding_window' | 'summarization' | undefined, + networkMode: options.networkMode as NetworkMode | undefined, + subnets: parseCommaSeparatedList(options.subnets), + securityGroups: parseCommaSeparatedList(options.securityGroups), + idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, + maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, + skipGit: options.skipGit, + skipInstall: options.skipInstall, + onProgress, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + printCreateHarnessSummary(options.name!); + } else { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); +} + +/** Handle CLI mode with progress output for the agent/runtime path */ +async function handleCreateAgentCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); const validation = validateCreateOptions(options, cwd); @@ -173,12 +280,12 @@ export const registerCreate = (program: Command) => { .description(COMMAND_DESCRIPTIONS.create) .option('--name ', 'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]') .option('--no-agent', 'Skip agent creation [non-interactive]') - .option('--defaults', 'Use defaults (Python, Strands, Bedrock, no memory) [non-interactive]') + .option('--defaults', 'Use defaults [non-interactive]') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') .option('--language ', 'Target language (default: Python) [non-interactive]') .option( '--framework ', - 'Agent framework (Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents) [non-interactive]' + 'Agent framework (Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents); triggers agent/runtime path [non-interactive]' ) .option('--model-provider ', 'Model provider (Bedrock, Anthropic, OpenAI, Gemini) [non-interactive]') .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') @@ -205,17 +312,15 @@ export const registerCreate = (program: Command) => { .option('--skip-install', 'Skip all dependency installation (npm install, uv sync) [non-interactive]') .option('--dry-run', 'Preview what would be created without making changes [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') + .option('--model-id ', 'Model ID for harness [non-interactive]') + .option('--api-key-arn ', 'API key ARN for non-Bedrock harness providers [non-interactive]') + .option('--no-harness-memory', 'Skip auto-creating memory for harness [non-interactive]') + .option('--max-iterations ', 'Max agent loop iterations (harness) [non-interactive]') + .option('--max-tokens ', 'Max tokens per iteration (harness) [non-interactive]') + .option('--timeout ', 'Max execution duration in seconds (harness) [non-interactive]') + .option('--truncation-strategy ', 'Truncation strategy: sliding_window or summarization (harness) [non-interactive]') .action(async options => { try { - // Apply defaults if --defaults flag is set - if (options.defaults) { - options.language = options.language ?? 'Python'; - options.build = options.build ?? 'CodeZip'; - options.framework = options.framework ?? 'Strands'; - options.modelProvider = options.modelProvider ?? 'Bedrock'; - options.memory = options.memory ?? 'none'; - } - // Any flag triggers non-interactive CLI mode const hasAnyFlag = Boolean( options.name ?? @@ -227,21 +332,73 @@ export const registerCreate = (program: Command) => { options.modelProvider ?? options.apiKey ?? options.memory ?? + options.protocol ?? + options.type ?? + options.agentId ?? + options.agentAliasId ?? + options.region ?? + options.networkMode ?? + options.subnets ?? + options.securityGroups ?? + options.idleTimeout ?? + options.maxLifetime ?? options.outputDir ?? options.skipGit ?? options.skipPythonSetup ?? options.skipInstall ?? options.dryRun ?? - options.json + options.json ?? + options.modelId ?? + options.apiKeyArn ?? + (options.harnessMemory === false ? true : null) ?? + options.maxIterations ?? + options.maxTokens ?? + options.timeout ?? + options.truncationStrategy ); - if (hasAnyFlag) { - // Default language to Python (only supported option) for CLI mode - options.language = options.language ?? 'Python'; - await handleCreateCLI(options as CreateOptions); - } else { + if (!hasAnyFlag) { handleCreateTUI(); + return; } + + // CLI mode: fork between harness and agent paths + const opts = options as CreateOptions; + + // Conflict detection: agent-path flags + harness-only flags + if (isAgentPath(opts) && hasHarnessOnlyFlags(opts)) { + const error = 'Cannot mix agent-path flags (--framework, --language, etc.) with harness-only flags (--model-id, --max-iterations, etc.)'; + if (opts.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + // --no-agent: bare project (no harness, no agent) + if (opts.agent === false) { + await handleCreateAgentCLI(opts); + return; + } + + // Agent path: any agent-specific flag triggers it + if (isAgentPath(opts)) { + // Apply agent defaults if --defaults + if (opts.defaults) { + opts.language = opts.language ?? 'Python'; + opts.build = opts.build ?? 'CodeZip'; + opts.framework = opts.framework ?? 'Strands'; + opts.modelProvider = opts.modelProvider ?? 'Bedrock'; + opts.memory = opts.memory ?? 'none'; + } + opts.language = opts.language ?? 'Python'; + await handleCreateAgentCLI(opts); + return; + } + + // Harness path (default) + await handleCreateHarnessCLI(opts); } catch (error) { render(Error: {getErrorMessage(error)}); process.exit(1); diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts new file mode 100644 index 000000000..85afc5e91 --- /dev/null +++ b/src/cli/commands/create/harness-action.ts @@ -0,0 +1,92 @@ +import { CONFIG_DIR } from '../../../lib'; +import type { HarnessModelProvider, NetworkMode } from '../../../schema'; +import { getErrorMessage } from '../../errors'; +import { harnessPrimitive } from '../../primitives/registry'; +import { createProject, type ProgressCallback } from './action'; +import type { CreateResult } from './types'; +import { join } from 'path'; + +export interface CreateHarnessProjectOptions { + name: string; + cwd: string; + modelProvider: HarnessModelProvider; + modelId: string; + apiKeyArn?: string; + skipMemory?: boolean; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + truncationStrategy?: 'sliding_window' | 'summarization'; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; + idleTimeout?: number; + maxLifetime?: number; + skipGit?: boolean; + skipInstall?: boolean; + onProgress?: ProgressCallback; +} + +export async function createProjectWithHarness(options: CreateHarnessProjectOptions): Promise { + const { name, cwd, skipGit, skipInstall, onProgress } = options; + + const projectResult = await createProject({ + name, + cwd, + skipGit, + skipInstall, + onProgress, + }); + + if (!projectResult.success) { + return projectResult; + } + + const projectRoot = projectResult.projectPath!; + const configBaseDir = join(projectRoot, CONFIG_DIR); + + try { + onProgress?.('Add harness to project', 'start'); + + const harnessResult = await harnessPrimitive.add({ + name: options.name, + modelProvider: options.modelProvider, + modelId: options.modelId, + apiKeyArn: options.apiKeyArn, + skipMemory: options.skipMemory, + maxIterations: options.maxIterations, + maxTokens: options.maxTokens, + timeoutSeconds: options.timeoutSeconds, + truncationStrategy: options.truncationStrategy, + networkMode: options.networkMode, + subnets: options.subnets, + securityGroups: options.securityGroups, + idleTimeout: options.idleTimeout, + maxLifetime: options.maxLifetime, + configBaseDir, + }); + + if (!harnessResult.success) { + onProgress?.('Add harness to project', 'error'); + return { + success: false, + error: harnessResult.error, + warnings: projectResult.warnings, + }; + } + + onProgress?.('Add harness to project', 'done'); + + return { + success: true, + projectPath: projectRoot, + warnings: projectResult.warnings, + }; + } catch (err) { + return { + success: false, + error: getErrorMessage(err), + warnings: projectResult.warnings, + }; + } +} diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts new file mode 100644 index 000000000..583ff54fd --- /dev/null +++ b/src/cli/commands/create/harness-validate.ts @@ -0,0 +1,80 @@ +import { HarnessNameSchema } from '../../../schema'; +import { validateFolderNotExists } from './validate'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +export interface CreateHarnessCliOptions { + name?: string; + modelProvider?: string; + modelId?: string; + apiKeyArn?: string; + noMemory?: boolean; + maxIterations?: string; + maxTokens?: string; + timeout?: string; + truncationStrategy?: string; + networkMode?: string; + subnets?: string; + securityGroups?: string; + idleTimeout?: string; + maxLifetime?: string; + outputDir?: string; + skipGit?: boolean; + skipInstall?: boolean; + dryRun?: boolean; + json?: boolean; +} + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +const MODEL_PROVIDER_MAPPING: Record = { + 'bedrock': 'bedrock', + 'Bedrock': 'bedrock', + 'open_ai': 'open_ai', + 'OpenAI': 'open_ai', + 'gemini': 'gemini', + 'Gemini': 'gemini', +}; + +export function normalizeHarnessModelProvider(raw: string): string | undefined { + return MODEL_PROVIDER_MAPPING[raw]; +} + +export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, cwd?: string): ValidationResult { + if (!options.name) { + return { valid: false, error: '--name is required' }; + } + + const nameResult = HarnessNameSchema.safeParse(options.name); + if (!nameResult.success) { + return { valid: false, error: nameResult.error.issues[0]?.message ?? 'Invalid harness name' }; + } + + const folderCheck = validateFolderNotExists(options.name, cwd ?? process.cwd()); + if (folderCheck !== true) { + return { valid: false, error: folderCheck }; + } + + if (options.modelProvider) { + const normalized = normalizeHarnessModelProvider(options.modelProvider); + if (!normalized) { + return { valid: false, error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, or gemini` }; + } + options.modelProvider = normalized; + } else { + options.modelProvider = 'bedrock'; + } + + if (!options.modelId) { + options.modelId = 'us.anthropic.claude-sonnet-4-5-20250514-v1:0'; + } + + if (options.modelProvider !== 'bedrock' && !options.apiKeyArn) { + return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` }; + } + + return { valid: true }; +} diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index eee545609..63430dd54 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -23,6 +23,14 @@ export interface CreateOptions extends VpcOptions { skipInstall?: boolean; dryRun?: boolean; json?: boolean; + // Harness-specific + modelId?: string; + apiKeyArn?: string; + harnessMemory?: boolean; + maxIterations?: string; + maxTokens?: string; + timeout?: string; + truncationStrategy?: string; } export interface CreateResult { diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 500eb6cda..e485398df 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -452,23 +452,20 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise; + harnessDeployError = postCdkResult.error; + } else { + const harnessResult = postCdkResult.results.get('harness'); + if (harnessResult?.state) { + deployedHarnesses = harnessResult.state as Record; + } + endStep('success'); } - endStep('success'); } const deployedState = buildDeployedState({ @@ -488,6 +485,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { const gatewayUrls = Object.entries(gateways) diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index 237d31532..fc7369c51 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -2,6 +2,7 @@ export type ResourceType = | 'agent' | 'gateway' | 'gateway-target' + | 'harness' | 'memory' | 'credential' | 'evaluator' diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 2bfffcaa4..62a19c2b2 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -9,6 +9,7 @@ export interface RemoveLoggerOptions { /** Type of resource being removed */ resourceType: | 'agent' + | 'harness' | 'memory' | 'credential' | 'gateway' diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 12e172d17..ff4af8c5f 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -78,7 +78,7 @@ describe('validateProject', () => { mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); await expect(validateProject()).rejects.toThrow( - 'No resources defined in project. Add at least one resource (agent, memory, evaluator, or gateway) before deploying.' + 'No resources defined in project. Add at least one resource (agent, memory, evaluator, gateway, or harness) before deploying.' ); }); diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts new file mode 100644 index 000000000..987ff5bb1 --- /dev/null +++ b/src/cli/primitives/HarnessPrimitive.ts @@ -0,0 +1,337 @@ +import { ConfigIO, findConfigRoot } from '../../lib'; +import type { HarnessModelProvider, HarnessSpec, NetworkMode } from '../../schema'; +import { HarnessNameSchema, HarnessSpecSchema } from '../../schema'; +import { deleteHarness } from '../aws/agentcore-harness'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { DEFAULT_MEMORY_EXPIRY_DAYS } from '../tui/screens/generate/defaults'; +import { BasePrimitive } from './BasePrimitive'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; +import { rm, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; + +export interface AddHarnessOptions { + name: string; + modelProvider: HarnessModelProvider; + modelId: string; + apiKeyArn?: string; + systemPrompt?: string; + skipMemory?: boolean; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + truncationStrategy?: 'sliding_window' | 'summarization'; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; + idleTimeout?: number; + maxLifetime?: number; + configBaseDir?: string; +} + +export type RemovableHarness = RemovableResource; + +export class HarnessPrimitive extends BasePrimitive { + readonly kind = 'harness' as const; + readonly label = 'Harness'; + readonly primitiveSchema = HarnessSpecSchema; + + async add(options: AddHarnessOptions): Promise> { + try { + const configBaseDir = options.configBaseDir ?? findConfigRoot(); + if (!configBaseDir) { + return { success: false, error: 'No agentcore project found. Run `agentcore create` first.' }; + } + + const configIO = new ConfigIO({ baseDir: configBaseDir }); + const project = await this.readProjectSpec(configIO); + + const harnesses = project.harnesses ?? []; + this.checkDuplicate(harnesses, options.name); + + const memoryName = options.skipMemory ? undefined : `${options.name}Memory`; + + const harnessSpec: HarnessSpec = { + name: options.name, + model: { + provider: options.modelProvider, + modelId: options.modelId, + ...(options.apiKeyArn && { apiKeyArn: options.apiKeyArn }), + }, + tools: [], + skills: [], + ...(options.systemPrompt && { systemPrompt: options.systemPrompt }), + ...(memoryName && { memory: { name: memoryName } }), + ...(options.maxIterations !== undefined && { maxIterations: options.maxIterations }), + ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }), + ...(options.timeoutSeconds !== undefined && { timeoutSeconds: options.timeoutSeconds }), + ...(options.truncationStrategy && { truncation: { strategy: options.truncationStrategy } }), + ...(options.networkMode && { networkMode: options.networkMode }), + ...(options.networkMode === 'VPC' && + options.subnets && + options.securityGroups && { + networkConfig: { + subnets: options.subnets, + securityGroups: options.securityGroups, + }, + }), + ...(this.buildLifecycleConfig(options) && { lifecycleConfig: this.buildLifecycleConfig(options) }), + }; + + await configIO.writeHarnessSpec(options.name, harnessSpec); + + const pathResolver = configIO.getPathResolver(); + const harnessDir = pathResolver.getHarnessDir(options.name); + const systemPromptPath = join(harnessDir, 'system-prompt.md'); + const systemPromptContent = options.systemPrompt || '# System Prompt\n\nEnter your system prompt here.\n'; + await writeFile(systemPromptPath, systemPromptContent, 'utf-8'); + + if (memoryName) { + project.memories.push({ + name: memoryName, + eventExpiryDuration: DEFAULT_MEMORY_EXPIRY_DAYS, + strategies: [], + }); + } + + project.harnesses = [ + ...harnesses, + { + name: options.name, + path: `./harnesses/${options.name}`, + }, + ]; + + await this.writeProjectSpec(project, configIO); + + return { success: true, harnessName: options.name }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async remove(harnessName: string): Promise { + try { + const configRoot = findConfigRoot(); + if (!configRoot) { + return { success: false, error: 'No agentcore project found.' }; + } + + const configIO = new ConfigIO({ baseDir: configRoot }); + const project = await this.readProjectSpec(configIO); + + const harnesses = project.harnesses ?? []; + const harnessIndex = harnesses.findIndex(h => h.name === harnessName); + + if (harnessIndex === -1) { + return { success: false, error: `Harness "${harnessName}" not found.` }; + } + + // Delete harness from AWS if it's deployed + try { + const deployedState = await configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const deployedHarness = target.resources?.harnesses?.[harnessName]; + if (deployedHarness) { + const targets = await configIO.resolveAWSDeploymentTargets(); + const region = targets[0]?.region; + if (region) { + await deleteHarness({ region, harnessId: deployedHarness.harnessId }); + } + delete target.resources!.harnesses![harnessName]; + await configIO.writeDeployedState(deployedState); + break; + } + } + } catch { + // AWS deletion is best-effort; next deploy will clean up + } + + harnesses.splice(harnessIndex, 1); + project.harnesses = harnesses; + + await this.writeProjectSpec(project, configIO); + + const pathResolver = configIO.getPathResolver(); + const harnessDir = pathResolver.getHarnessDir(harnessName); + await rm(harnessDir, { recursive: true, force: true }); + + return { success: true }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async previewRemove(harnessName: string): Promise { + const project = await this.readProjectSpec(); + + const harnesses = project.harnesses ?? []; + const harness = harnesses.find(h => h.name === harnessName); + + if (!harness) { + throw new Error(`Harness "${harnessName}" not found.`); + } + + const summary: string[] = [`Removing harness: ${harnessName}`]; + const directoriesToDelete: string[] = [`harnesses/${harnessName}`]; + const schemaChanges: SchemaChange[] = []; + + const afterSpec = { + ...project, + harnesses: harnesses.filter(h => h.name !== harnessName), + }; + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete, schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + const harnesses = project.harnesses ?? []; + return harnesses.map(h => ({ name: h.name })); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('harness') + .description('Add a harness to the project') + .option('--name ', 'Harness name (start with letter, alphanumeric + underscores, max 48 chars)') + .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini') + .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)') + .option('--api-key-arn ', 'API key ARN for non-Bedrock providers') + .option('--no-memory', 'Skip auto-creating memory') + .option('--max-iterations ', 'Max iterations', parseInt) + .option('--max-tokens ', 'Max tokens', parseInt) + .option('--timeout ', 'Timeout in seconds', parseInt) + .option('--truncation-strategy ', 'Truncation strategy: sliding_window or summarization') + .option('--network-mode ', 'Network mode: PUBLIC or VPC') + .option('--subnets ', 'Comma-separated subnet IDs (for VPC mode)') + .option('--security-groups ', 'Comma-separated security group IDs (for VPC mode)') + .option('--idle-timeout ', 'Idle timeout in seconds', parseInt) + .option('--max-lifetime ', 'Max lifetime in seconds', parseInt) + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + name?: string; + modelProvider?: string; + modelId?: string; + apiKeyArn?: string; + memory?: boolean; + maxIterations?: number; + maxTokens?: number; + timeout?: number; + truncationStrategy?: string; + networkMode?: string; + subnets?: string; + securityGroups?: string; + idleTimeout?: number; + maxLifetime?: number; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.json) { + if (!cliOptions.name || !cliOptions.modelProvider || !cliOptions.modelId) { + const error = '--name, --model-provider, and --model-id are required'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const result = await this.add({ + name: cliOptions.name, + modelProvider: cliOptions.modelProvider as HarnessModelProvider, + modelId: cliOptions.modelId, + apiKeyArn: cliOptions.apiKeyArn, + skipMemory: cliOptions.memory === false, + maxIterations: cliOptions.maxIterations, + maxTokens: cliOptions.maxTokens, + timeoutSeconds: cliOptions.timeout, + truncationStrategy: cliOptions.truncationStrategy as 'sliding_window' | 'summarization' | undefined, + networkMode: cliOptions.networkMode as NetworkMode | undefined, + subnets: cliOptions.subnets?.split(',').map(s => s.trim()), + securityGroups: cliOptions.securityGroups?.split(',').map(s => s.trim()), + idleTimeout: cliOptions.idleTimeout, + maxLifetime: cliOptions.maxLifetime, + }); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.error(result.error); + } + process.exit(1); + } + + // Deploy harness to AWS + if (!cliOptions.json) { + console.log(`Added harness '${result.harnessName}'. Deploying...`); + } + try { + const { handleDeploy } = await import('../commands/deploy/actions'); + const deployResult = await handleDeploy({ target: 'default', autoConfirm: true }); + if (cliOptions.json) { + console.log(JSON.stringify({ ...result, deployed: deployResult.success })); + } else if (deployResult.success) { + console.log('Harness deployed successfully.'); + } else { + console.warn(`Deploy warning: ${deployResult.error}`); + } + } catch (err) { + if (cliOptions.json) { + console.log(JSON.stringify({ ...result, deployed: false })); + } else { + console.warn(`Deploy warning: ${getErrorMessage(err)}`); + } + } + + process.exit(0); + } else { + console.error('Interactive mode is not yet implemented for harnesses.'); + console.error('Use --name, --model-provider, and --model-id flags to add a harness.'); + process.exit(1); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + } + ); + + this.registerRemoveSubcommand(removeCmd); + } + + addScreen(): AddScreenComponent { + return null; + } + + private buildLifecycleConfig(options: { idleTimeout?: number; maxLifetime?: number }) { + if (options.idleTimeout === undefined && options.maxLifetime === undefined) return undefined; + return { + ...(options.idleTimeout !== undefined && { idleRuntimeSessionTimeout: options.idleTimeout }), + ...(options.maxLifetime !== undefined && { maxLifetime: options.maxLifetime }), + }; + } +} diff --git a/src/cli/primitives/__tests__/HarnessPrimitive.test.ts b/src/cli/primitives/__tests__/HarnessPrimitive.test.ts new file mode 100644 index 000000000..c57ddabba --- /dev/null +++ b/src/cli/primitives/__tests__/HarnessPrimitive.test.ts @@ -0,0 +1,327 @@ +import type { AgentCoreProjectSpec, NetworkMode } from '../../../schema'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HarnessPrimitive } from '../HarnessPrimitive'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); +const mockWriteHarnessSpec = vi.fn(); +const mockGetHarnessDir = vi.fn().mockReturnValue('/tmp/test/agentcore/harnesses/test'); + +vi.mock('../../../lib', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + writeHarnessSpec = mockWriteHarnessSpec; + getPathResolver = () => ({ + getHarnessDir: mockGetHarnessDir, + }); + hasProject = vi.fn().mockReturnValue(true); + }, + findConfigRoot: vi.fn().mockReturnValue('/tmp/test/agentcore'), +})); + +vi.mock('fs/promises', () => ({ + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), +})); + +const baseProject: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK', + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + harnesses: [], +}; + +describe('HarnessPrimitive', () => { + let primitive: HarnessPrimitive; + + beforeEach(() => { + vi.clearAllMocks(); + primitive = new HarnessPrimitive(); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockWriteHarnessSpec.mockResolvedValue(undefined); + }); + + describe('add()', () => { + it('creates harness spec and registry entry', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + const result = await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.harnessName).toBe('testHarness'); + } + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + name: 'testHarness', + model: { + provider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }, + })); + + expect(mockWriteProjectSpec).toHaveBeenCalledWith( + expect.objectContaining({ + harnesses: [{ name: 'testHarness', path: './harnesses/testHarness' }], + }) + ); + }); + + it('auto-creates memory entry when skipMemory is false', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }); + + expect(mockWriteProjectSpec).toHaveBeenCalledWith( + expect.objectContaining({ + memories: expect.arrayContaining([ + expect.objectContaining({ + name: 'testHarnessMemory', + eventExpiryDuration: 30, + strategies: [], + }), + ]), + }) + ); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + memory: { name: 'testHarnessMemory' }, + })); + }); + + it('sets memory reference in harness spec', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + memory: { name: 'testHarnessMemory' }, + })); + }); + + it('rejects duplicate harness name', async () => { + mockReadProjectSpec.mockResolvedValue({ + ...baseProject, + harnesses: [{ name: 'testHarness', path: './harnesses/testHarness' }], + }); + + const result = await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('already exists'); + } + }); + + it('skips memory when skipMemory is true', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + skipMemory: true, + }); + + expect(mockWriteProjectSpec).toHaveBeenCalledWith( + expect.objectContaining({ + memories: [], + }) + ); + + const harnessSpec = mockWriteHarnessSpec.mock.calls[0]![1]; + expect(harnessSpec).not.toHaveProperty('memory'); + }); + + it('includes execution limits in harness spec', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + maxIterations: 10, + maxTokens: 4096, + timeoutSeconds: 300, + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + maxIterations: 10, + maxTokens: 4096, + timeoutSeconds: 300, + })); + }); + + it('includes truncation strategy in harness spec', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + truncationStrategy: 'sliding_window', + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + truncation: { + strategy: 'sliding_window', + }, + })); + }); + + it('includes network config for VPC mode', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + networkMode: 'VPC' as NetworkMode, + subnets: ['subnet-123', 'subnet-456'], + securityGroups: ['sg-789'], + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + networkMode: 'VPC', + networkConfig: { + subnets: ['subnet-123', 'subnet-456'], + securityGroups: ['sg-789'], + }, + })); + }); + + it('includes lifecycle config when provided', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + idleTimeout: 600, + maxLifetime: 3600, + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + lifecycleConfig: { + idleRuntimeSessionTimeout: 600, + maxLifetime: 3600, + }, + })); + }); + + it('includes API key ARN for non-Bedrock providers', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'open_ai', + modelId: 'gpt-4', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + model: { + provider: 'open_ai', + modelId: 'gpt-4', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + }, + })); + }); + + it('includes system prompt when provided', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + systemPrompt: 'You are a helpful assistant.', + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ + systemPrompt: 'You are a helpful assistant.', + })); + }); + }); + + describe('remove()', () => { + it('removes harness from registry', async () => { + const { rm } = await import('fs/promises'); + + mockReadProjectSpec.mockResolvedValue({ + ...baseProject, + harnesses: [{ name: 'testHarness', path: './harnesses/testHarness' }], + }); + + const result = await primitive.remove('testHarness'); + + expect(result.success).toBe(true); + expect(mockWriteProjectSpec).toHaveBeenCalledWith( + expect.objectContaining({ + harnesses: [], + }) + ); + expect(rm).toHaveBeenCalledWith('/tmp/test/agentcore/harnesses/test', { recursive: true, force: true }); + }); + + it('errors for nonexistent harness', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + const result = await primitive.remove('nonexistent'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('not found'); + } + }); + }); + + describe('getRemovable()', () => { + it('returns all registered harnesses', async () => { + mockReadProjectSpec.mockResolvedValue({ + ...baseProject, + harnesses: [ + { name: 'harness1', path: './harnesses/harness1' }, + { name: 'harness2', path: './harnesses/harness2' }, + ], + }); + + const removable = await primitive.getRemovable(); + + expect(removable).toEqual([{ name: 'harness1' }, { name: 'harness2' }]); + }); + + it('returns empty array when no harnesses exist', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + const removable = await primitive.getRemovable(); + + expect(removable).toEqual([]); + }); + }); +}); diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 2ef948e57..91efc2350 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -2,6 +2,7 @@ export { BasePrimitive } from './BasePrimitive'; export { MemoryPrimitive } from './MemoryPrimitive'; export { CredentialPrimitive } from './CredentialPrimitive'; export { AgentPrimitive } from './AgentPrimitive'; +export { HarnessPrimitive } from './HarnessPrimitive'; export { EvaluatorPrimitive } from './EvaluatorPrimitive'; export { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; export { GatewayPrimitive } from './GatewayPrimitive'; @@ -9,6 +10,7 @@ export { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; export { ALL_PRIMITIVES, agentPrimitive, + harnessPrimitive, memoryPrimitive, credentialPrimitive, evaluatorPrimitive, @@ -19,3 +21,4 @@ export { } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; export type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from './types'; +export type { AddHarnessOptions } from './HarnessPrimitive'; diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index fd46a6be7..63544cad3 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -4,6 +4,7 @@ import { CredentialPrimitive } from './CredentialPrimitive'; import { EvaluatorPrimitive } from './EvaluatorPrimitive'; import { GatewayPrimitive } from './GatewayPrimitive'; import { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; +import { HarnessPrimitive } from './HarnessPrimitive'; import { MemoryPrimitive } from './MemoryPrimitive'; import { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; import { PolicyEnginePrimitive } from './PolicyEnginePrimitive'; @@ -14,6 +15,7 @@ import type { RemovableResource } from './types'; * Singleton instances of all primitives. */ export const agentPrimitive = new AgentPrimitive(); +export const harnessPrimitive = new HarnessPrimitive(); export const memoryPrimitive = new MemoryPrimitive(); export const credentialPrimitive = new CredentialPrimitive(); export const evaluatorPrimitive = new EvaluatorPrimitive(); @@ -28,6 +30,7 @@ export const policyPrimitive = new PolicyPrimitive(); */ export const ALL_PRIMITIVES: BasePrimitive[] = [ agentPrimitive, + harnessPrimitive, memoryPrimitive, credentialPrimitive, evaluatorPrimitive, diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 85da20b00..407b8a7ff 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -8,6 +8,7 @@ import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; import { useAddAgent } from '../agent/useAddAgent'; import { AddEvaluatorFlow } from '../evaluator'; +import { AddHarnessFlow } from '../harness/AddHarnessFlow'; import { AddIdentityFlow } from '../identity'; import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; @@ -22,6 +23,7 @@ import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'select' } + | { name: 'harness-wizard' } | { name: 'agent-wizard' } | { name: 'gateway-wizard' } | { name: 'tool-wizard' } @@ -177,6 +179,9 @@ export function AddFlow(props: AddFlowProps) { const handleSelectResource = useCallback((resourceType: AddResourceType) => { switch (resourceType) { + case 'harness': + setFlow({ name: 'harness-wizard' }); + break; case 'agent': setFlow({ name: 'agent-wizard' }); break; @@ -247,6 +252,18 @@ export function AddFlow(props: AddFlowProps) { return ; } + if (flow.name === 'harness-wizard') { + return ( + setFlow({ name: 'select' })} + onExit={props.onExit} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + // Agent wizard - now uses AddAgentFlow with mode selection if (flow.name === 'agent-wizard') { return ( diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index d8241f8c5..4116c7e72 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -2,6 +2,7 @@ import type { SelectableItem } from '../../components'; import { SelectScreen } from '../../components'; const ADD_RESOURCES = [ + { id: 'harness', title: 'Harness', description: 'Managed agent loop, no code required' }, { id: 'agent', title: 'Agent', description: 'Deploy an HTTP, MCP, or A2A agent' }, { id: 'memory', title: 'Memory', description: 'Persistent context storage' }, { id: 'credential', title: 'Credential', description: 'API key credential providers' }, diff --git a/src/cli/tui/screens/create/CreateScreen.tsx b/src/cli/tui/screens/create/CreateScreen.tsx index 5b56a0ac1..76c4b7194 100644 --- a/src/cli/tui/screens/create/CreateScreen.tsx +++ b/src/cli/tui/screens/create/CreateScreen.tsx @@ -19,13 +19,20 @@ import { STATUS_COLORS } from '../../theme'; import { AddAgentScreen } from '../agent/AddAgentScreen'; import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; +import { AddHarnessScreen } from '../harness/AddHarnessScreen'; +import type { AddHarnessConfig } from '../harness/types'; import { useCreateFlow } from './useCreateFlow'; import { Box, Text, useApp } from 'ink'; import { join } from 'path'; import { useCallback, useEffect } from 'react'; /** Build a text representation of the completion screen for terminal output */ -function buildExitMessage(projectName: string, steps: Step[], agentConfig: AddAgentConfig | null): string { +function buildExitMessage( + projectName: string, + steps: Step[], + agentConfig: AddAgentConfig | null, + harnessConfig: AddHarnessConfig | null +): string { const lines: string[] = []; // Title @@ -63,6 +70,14 @@ function buildExitMessage(projectName: string, steps: Step[], agentConfig: AddAg const maxPathLen = Math.max(agentPath.length, agentcorePath.length); lines.push(` ${agentPath.padEnd(maxPathLen)} \x1b[2mAgent code location (empty)\x1b[0m`); lines.push(` ${agentcorePath.padEnd(maxPathLen)} \x1b[2mConfig and CDK project\x1b[0m`); + } else if (harnessConfig) { + const harnessPath = `harness/${harnessConfig.name}/`; + const agentcorePath = 'agentcore/'; + const maxPathLen = Math.max(harnessPath.length, agentcorePath.length); + lines.push(` ${harnessPath.padEnd(maxPathLen)} \x1b[2mHarness (managed agent loop)\x1b[0m`); + lines.push(` ${agentcorePath.padEnd(maxPathLen)} \x1b[2mConfig and CDK project\x1b[0m`); + lines.push(''); + lines.push(`\x1b[2mModel:\x1b[0m ${harnessConfig.modelId} \x1b[2mvia ${harnessConfig.modelProvider}\x1b[0m`); } else { lines.push(` agentcore/ \x1b[2mConfig and CDK project\x1b[0m`); } @@ -120,23 +135,35 @@ interface CreateScreenProps { } /** Next steps shown after successful project creation */ -function getCreateNextSteps(hasAgent: boolean): NextStep[] { +function getCreateNextSteps(hasAgent: boolean, hasHarness: boolean): NextStep[] { if (hasAgent) { return [ { command: 'dev', label: 'Run agent locally' }, { command: 'deploy', label: 'Deploy to AWS' }, ]; } + if (hasHarness) { + return [{ command: 'deploy', label: 'Deploy to AWS' }]; + } return [{ command: 'add', label: 'Add an agent' }]; } -const CREATE_PROMPT_ITEMS = [ - { id: 'yes', title: 'Yes, add an agent' }, - { id: 'no', title: "No, I'll do it later" }, +const CREATE_TYPE_ITEMS = [ + { id: 'harness', title: 'Harness (recommended)', description: 'Managed agent loop, no code required' }, + { id: 'agent', title: 'Agent Runtime', description: 'Start with a template or bring your own code' }, + { id: 'skip', title: 'Skip', description: "I'll add resources later" }, ]; /** Tree-style display of created project structure */ -function CreatedSummary({ projectName, agentConfig }: { projectName: string; agentConfig: AddAgentConfig | null }) { +function CreatedSummary({ + projectName, + agentConfig, + harnessConfig, +}: { + projectName: string; + agentConfig: AddAgentConfig | null; + harnessConfig: AddHarnessConfig | null; +}) { const getFrameworkLabel = (framework: string) => { const option = FRAMEWORK_OPTIONS.find(o => o.id === framework); return option?.title ?? framework; @@ -145,8 +172,10 @@ function CreatedSummary({ projectName, agentConfig }: { projectName: string; age const isCreate = agentConfig?.agentType === 'create' || agentConfig?.agentType === 'import'; const isByo = agentConfig?.agentType === 'byo'; const agentPath = isCreate ? `app/${agentConfig.name}/` : isByo ? agentConfig.codeLocation : null; + const harnessPath = harnessConfig ? `harness/${harnessConfig.name}/` : null; + const resourcePath = agentPath || harnessPath; const agentcorePath = 'agentcore/'; - const maxPathLen = agentPath ? Math.max(agentPath.length, agentcorePath.length) : agentcorePath.length; + const maxPathLen = resourcePath ? Math.max(resourcePath.length, agentcorePath.length) : agentcorePath.length; return ( @@ -172,6 +201,14 @@ function CreatedSummary({ projectName, agentConfig }: { projectName: string; age )} + {harnessConfig && harnessPath && ( + + + {harnessPath.padEnd(maxPathLen)} + {' '}Harness (managed agent loop) + + + )} {agentcorePath.padEnd(maxPathLen)} @@ -186,6 +223,13 @@ function CreatedSummary({ projectName, agentConfig }: { projectName: string; age via {agentConfig.modelProvider} )} + {harnessConfig && ( + + Model: + {harnessConfig.modelId} + via {harnessConfig.modelProvider} + + )} {isByo && agentConfig && ( @@ -213,12 +257,12 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS const handleExit = useCallback(() => { if (allSuccess && isInteractive) { // Set message to be printed after TUI exits (full completion screen) - setExitMessage(buildExitMessage(flow.projectName, flow.steps, flow.addAgentConfig)); + setExitMessage(buildExitMessage(flow.projectName, flow.steps, flow.addAgentConfig, flow.addHarnessConfig)); exit(); } else { onExit(); } - }, [allSuccess, isInteractive, flow.projectName, flow.steps, flow.addAgentConfig, exit, onExit]); + }, [allSuccess, isInteractive, flow.projectName, flow.steps, flow.addAgentConfig, flow.addHarnessConfig, exit, onExit]); // Auto-exit when project creation completes successfully useEffect(() => { @@ -227,14 +271,14 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS } }, [allSuccess, handleExit]); - // Create prompt navigation - const { selectedIndex: createPromptIndex } = useListNavigation({ - items: CREATE_PROMPT_ITEMS, + // Create type selection navigation + const { selectedIndex: createTypeIndex } = useListNavigation({ + items: CREATE_TYPE_ITEMS, onSelect: item => { - flow.setWantsCreate(item.id === 'yes'); + flow.handleCreateTypeSelection(item.id as 'harness' | 'agent' | 'skip'); }, onExit: handleExit, - isActive: flow.phase === 'create-prompt', + isActive: flow.phase === 'create-type-prompt', }); // Checking phase: brief loading state @@ -286,8 +330,8 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS ); } - // Create prompt phase - if (flow.phase === 'create-prompt') { + // Create type selection phase + if (flow.phase === 'create-type-prompt') { return ( @@ -296,9 +340,9 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS - Would you like to add an agent now? + What would you like to build? - + @@ -316,6 +360,17 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS ); } + // Harness wizard phase + if (flow.phase === 'harness-wizard') { + return ( + + ); + } + // Running/complete phase: show progress const headerContent = ( @@ -332,14 +387,18 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS {allSuccess && flow.outputDir && ( - + {isInteractive ? ( Project created successfully! ) : ( { if (onNavigate) { diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 91610b47a..de9ba234c 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -20,6 +20,7 @@ import { withMinDuration } from '../../utils'; import { mapByoConfigToAgent } from '../agent'; import type { AddAgentConfig } from '../agent/types'; import type { GenerateConfig } from '../generate/types'; +import type { AddHarnessConfig } from '../harness/types'; import { mkdir } from 'fs/promises'; import { basename, join } from 'path'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -28,8 +29,9 @@ type CreatePhase = | 'checking' | 'existing-project-error' | 'input' - | 'create-prompt' + | 'create-type-prompt' | 'create-wizard' + | 'harness-wizard' | 'running' | 'complete'; @@ -45,16 +47,23 @@ interface CreateFlowState { // Project name actions setProjectName: (name: string) => void; confirmProjectName: () => void; - // Create prompt actions - wantsCreate: boolean; - setWantsCreate: (wants: boolean) => void; + // Create type selection + handleCreateTypeSelection: (choice: 'harness' | 'agent' | 'skip') => void; // Add agent config (set when AddAgentScreen completes) addAgentConfig: AddAgentConfig | null; handleAddAgentComplete: (config: AddAgentConfig) => void; goBackFromAddAgent: () => void; + // Add harness config (set when AddHarnessScreen completes) + addHarnessConfig: AddHarnessConfig | null; + handleAddHarnessComplete: (config: AddHarnessConfig) => void; + goBackFromHarnessWizard: () => void; } -function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null): Step[] { +function getCreateSteps( + projectName: string, + agentConfig: AddAgentConfig | null, + harnessConfig: AddHarnessConfig | null +): Step[] { const steps: Step[] = [{ label: `Create ${projectName}/ project directory`, status: 'pending' }]; if (agentConfig) { @@ -62,6 +71,8 @@ function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null) if (agentConfig.language === 'Python' && agentConfig.agentType === 'create') { steps.push({ label: 'Set up Python environment', status: 'pending' }); } + } else if (harnessConfig) { + steps.push({ label: 'Add harness to project', status: 'pending' }); } steps.push({ label: 'Prepare agentcore/ directory', status: 'pending' }); @@ -107,12 +118,12 @@ export function useCreateFlow(cwd: string): CreateFlowState { const [outputDir, setOutputDir] = useState(); const [logFilePath, setLogFilePath] = useState(); - // Create prompt state - const [wantsCreate, setWantsCreate] = useState(false); - // Add agent config (from AddAgentScreen) const [addAgentConfig, setAddAgentConfig] = useState(null); + // Add harness config (from AddHarnessScreen) + const [addHarnessConfig, setAddHarnessConfig] = useState(null); + // Logger ref for the create operation const loggerRef = useRef(null); @@ -137,24 +148,28 @@ export function useCreateFlow(cwd: string): CreateFlowState { }, [cwd, phase]); const confirmProjectName = useCallback(() => { - setPhase('create-prompt'); + setPhase('create-type-prompt'); }, []); const updateStep = (index: number, update: Partial) => { setSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...update } : s))); }; - // Create prompt handlers - const handleSetWantsCreate = useCallback( - (wants: boolean) => { - setWantsCreate(wants); - if (wants) { - setAddAgentConfig(null); // Reset any previous config + // Create type selection handler + const handleCreateTypeSelection = useCallback( + (choice: 'harness' | 'agent' | 'skip') => { + if (choice === 'harness') { + setAddAgentConfig(null); + setAddHarnessConfig(null); + setPhase('harness-wizard'); + } else if (choice === 'agent') { + setAddAgentConfig(null); + setAddHarnessConfig(null); setPhase('create-wizard'); } else { - // Skip add agent, go straight to running setAddAgentConfig(null); - setSteps(getCreateSteps(projectName, null)); + setAddHarnessConfig(null); + setSteps(getCreateSteps(projectName, null, null)); setPhase('running'); } }, @@ -165,15 +180,30 @@ export function useCreateFlow(cwd: string): CreateFlowState { const handleAddAgentComplete = useCallback( (config: AddAgentConfig) => { setAddAgentConfig(config); - setSteps(getCreateSteps(projectName, config)); + setSteps(getCreateSteps(projectName, config, null)); setPhase('running'); }, [projectName] ); - // Go back from add agent wizard to create prompt + // Go back from add agent wizard to create type prompt const goBackFromAddAgent = useCallback(() => { - setPhase('create-prompt'); + setPhase('create-type-prompt'); + }, []); + + // Handle completion from AddHarnessScreen + const handleAddHarnessComplete = useCallback( + (config: AddHarnessConfig) => { + setAddHarnessConfig(config); + setSteps(getCreateSteps(projectName, null, config)); + setPhase('running'); + }, + [projectName] + ); + + // Go back from harness wizard to create type prompt + const goBackFromHarnessWizard = useCallback(() => { + setPhase('create-type-prompt'); }, []); // Main running effect @@ -448,6 +478,46 @@ export function useCreateFlow(cwd: string): CreateFlowState { } } + // Step: Add harness to project (if addHarnessConfig is set) + if (!addAgentConfig && addHarnessConfig) { + logger.startStep('Add harness to project'); + updateStep(stepIndex, { status: 'running' }); + try { + await withMinDuration(async () => { + logger.logSubStep(`Adding harness: ${addHarnessConfig.name}`); + const { harnessPrimitive } = await import('../../../primitives/registry'); + const result = await harnessPrimitive.add({ + name: addHarnessConfig.name, + modelProvider: addHarnessConfig.modelProvider, + modelId: addHarnessConfig.modelId, + apiKeyArn: addHarnessConfig.apiKeyArn, + maxIterations: addHarnessConfig.maxIterations, + maxTokens: addHarnessConfig.maxTokens, + timeoutSeconds: addHarnessConfig.timeoutSeconds, + truncationStrategy: addHarnessConfig.truncationStrategy, + networkMode: addHarnessConfig.networkMode, + subnets: addHarnessConfig.subnets, + securityGroups: addHarnessConfig.securityGroups, + idleTimeout: addHarnessConfig.idleTimeout, + maxLifetime: addHarnessConfig.maxLifetime, + configBaseDir, + }); + if (!result.success) { + throw new Error(result.error); + } + }); + logger.endStep('success'); + updateStep(stepIndex, { status: 'success' }); + stepIndex++; + } catch (err) { + const errMsg = getErrorMessage(err); + logger.endStep('error', errMsg); + updateStep(stepIndex, { status: 'error', error: errMsg }); + logger.finalize(false); + return; + } + } + // Step: Create CDK project logger.startStep('Prepare agentcore/ directory (CDK project)'); updateStep(stepIndex, { status: 'running' }); @@ -483,6 +553,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { logger.endStep('success'); updateStep(stepIndex, { status: 'success' }); } + stepIndex++; logger.finalize(true); setPhase('complete'); @@ -519,12 +590,15 @@ export function useCreateFlow(cwd: string): CreateFlowState { logFilePath, setProjectName, confirmProjectName, - // Create prompt - wantsCreate, - setWantsCreate: handleSetWantsCreate, + // Create type selection + handleCreateTypeSelection, // Add agent addAgentConfig, handleAddAgentComplete, goBackFromAddAgent, + // Add harness + addHarnessConfig, + handleAddHarnessComplete, + goBackFromHarnessWizard, }; } diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 0da441d0c..2341729be 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,4 +1,5 @@ import { ConfigIO } from '../../../../lib'; +import type { DeployedState, HarnessDeployedState } from '../../../../schema'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; import { buildDeployedState, @@ -15,6 +16,7 @@ import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from import { ExecLogger } from '../../../logging'; import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; +import { createDeploymentManager } from '../../../operations/deploy/imperative'; import { type StackDiffSummary, type Step, @@ -287,6 +289,37 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState setStackOutputs(outputs); const existingState = await configIO.readDeployedState().catch(() => undefined); + + // Post-CDK: deploy imperative resources (harness) + let deployedHarnesses: Record | undefined; + const imperativeManager = createDeploymentManager(); + const imperativeDeployedState = existingState ?? { targets: {} }; + const imperativeContext = { + projectSpec: ctx.projectSpec, + target, + configIO, + deployedState: imperativeDeployedState, + cdkOutputs: outputs, + onProgress: (step: string, status: 'start' | 'done' | 'error') => { + logger.log(`${step}: ${status}`); + }, + }; + + if (imperativeManager.hasDeployersForPhase('post-cdk', imperativeContext)) { + logger.startStep('Deploy harnesses'); + const postCdkResult = await imperativeManager.runPhase('post-cdk', imperativeContext); + if (!postCdkResult.success) { + logger.endStep('error', postCdkResult.error); + logger.log(`Harness deployment failed (CDK state will still be persisted): ${postCdkResult.error}`, 'warn'); + } else { + const harnessResult = postCdkResult.results.get('harness'); + if (harnessResult?.state) { + deployedHarnesses = harnessResult.state as Record; + } + logger.endStep('success'); + } + } + const deployedState = buildDeployedState({ targetName: target.name, stackName: currentStackName, @@ -300,6 +333,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState credentials: Object.keys(allCredentials).length > 0 ? allCredentials : undefined, policyEngines, policies, + harnesses: deployedHarnesses, }); await configIO.writeDeployedState(deployedState); @@ -386,6 +420,33 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState await cdkToolkitWrapper.deploy(); if (context?.isTeardownDeploy) { + // Teardown imperative resources (harnesses) before destroying the stack + const teardownTarget = context.awsTargets[0]; + if (teardownTarget) { + const imperativeManager = createDeploymentManager(); + const configIO = new ConfigIO(); + const existingTeardownState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); + const teardownContext = { + projectSpec: context.projectSpec, + target: teardownTarget, + configIO, + deployedState: existingTeardownState, + onProgress: (step: string, status: 'start' | 'done' | 'error') => { + logger.log(`${step}: ${status}`); + }, + }; + + if (imperativeManager.hasDeployersForPhase('post-cdk', teardownContext)) { + logger.startStep('Tear down imperative resources'); + const teardownResult = await imperativeManager.teardownAll(teardownContext); + if (!teardownResult.success) { + logger.endStep('error', teardownResult.error); + throw new Error(`Imperative teardown failed: ${teardownResult.error}`); + } + logger.endStep('success'); + } + } + // After deploying the empty spec, destroy the stack entirely const targetName = context.awsTargets[0]?.name; if (targetName) { diff --git a/src/cli/tui/screens/harness/AddHarnessFlow.tsx b/src/cli/tui/screens/harness/AddHarnessFlow.tsx new file mode 100644 index 000000000..2eb0fa28a --- /dev/null +++ b/src/cli/tui/screens/harness/AddHarnessFlow.tsx @@ -0,0 +1,116 @@ +import { ErrorPrompt } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddHarnessScreen } from './AddHarnessScreen'; +import type { AddHarnessConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { name: 'create-success'; harnessName: string; loading?: boolean; loadingMessage?: string } + | { name: 'error'; message: string }; + +interface AddHarnessFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddHarnessFlowProps) { + const [flow, setFlow] = useState({ name: 'create-wizard' }); + const [existingNames, setExistingNames] = useState([]); + + useEffect(() => { + void (async () => { + try { + const { ConfigIO } = await import('../../../../lib'); + const configIO = new ConfigIO(); + if (configIO.hasProject()) { + const project = await configIO.readProjectSpec(); + setExistingNames((project.harnesses ?? []).map(h => h.name)); + } + } catch { + // ignore + } + })(); + }, []); + + useEffect(() => { + if (!isInteractive && flow.name === 'create-success' && !flow.loading) { + onExit(); + } + }, [isInteractive, flow, onExit]); + + const handleCreateComplete = useCallback(async (config: AddHarnessConfig) => { + setFlow({ name: 'create-success', harnessName: config.name, loading: true, loadingMessage: 'Creating harness...' }); + try { + const { harnessPrimitive } = await import('../../../primitives/registry'); + const result = await harnessPrimitive.add({ + name: config.name, + modelProvider: config.modelProvider, + modelId: config.modelId, + apiKeyArn: config.apiKeyArn, + maxIterations: config.maxIterations, + maxTokens: config.maxTokens, + timeoutSeconds: config.timeoutSeconds, + truncationStrategy: config.truncationStrategy, + networkMode: config.networkMode, + subnets: config.subnets, + securityGroups: config.securityGroups, + idleTimeout: config.idleTimeout, + maxLifetime: config.maxLifetime, + }); + if (!result.success) { + setFlow({ name: 'error', message: result.error }); + return; + } + + // Deploy harness to AWS + setFlow({ name: 'create-success', harnessName: config.name, loading: true, loadingMessage: 'Deploying harness to AWS...' }); + try { + const { handleDeploy } = await import('../../../commands/deploy/actions'); + const deployResult = await handleDeploy({ target: 'default', autoConfirm: true }); + if (!deployResult.success) { + setFlow({ name: 'create-success', harnessName: config.name }); + return; + } + } catch { + // Deploy failure is non-fatal for add + } + setFlow({ name: 'create-success', harnessName: config.name }); + } catch (err) { + const { getErrorMessage } = await import('../../../errors'); + setFlow({ name: 'error', message: getErrorMessage(err) }); + } + }, []); + + if (flow.name === 'create-wizard') { + return ; + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ( + setFlow({ name: 'create-wizard' })} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/harness/AddHarnessScreen.tsx b/src/cli/tui/screens/harness/AddHarnessScreen.tsx new file mode 100644 index 000000000..6aa8dac33 --- /dev/null +++ b/src/cli/tui/screens/harness/AddHarnessScreen.tsx @@ -0,0 +1,369 @@ +import type { HarnessModelProvider } from '../../../../schema'; +import { NetworkModeSchema } from '../../../../schema'; +import { HarnessNameSchema, HarnessTruncationStrategySchema } from '../../../../schema/schemas/primitives/harness'; +import { ARN_VALIDATION_MESSAGE, isValidArn } from '../../../commands/shared/arn-utils'; +import { + ConfirmReview, + Panel, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddHarnessConfig, AdvancedSetting } from './types'; +import { + ADVANCED_SETTING_OPTIONS, + BEDROCK_MODEL_OPTIONS, + HARNESS_STEP_LABELS, + MODEL_PROVIDER_OPTIONS, + NETWORK_MODE_OPTIONS, + TRUNCATION_STRATEGY_OPTIONS, +} from './types'; +import { useAddHarnessWizard } from './useAddHarnessWizard'; +import React, { useMemo } from 'react'; + +interface AddHarnessScreenProps { + existingHarnessNames: string[]; + onComplete: (config: AddHarnessConfig) => void; + onExit: () => void; +} + +export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: AddHarnessScreenProps) { + const wizard = useAddHarnessWizard(); + + const modelProviderItems: SelectableItem[] = useMemo( + () => MODEL_PROVIDER_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const bedrockModelItems: SelectableItem[] = useMemo( + () => BEDROCK_MODEL_OPTIONS.map(opt => ({ id: opt.id, title: opt.title })), + [] + ); + + const advancedSettingItems: SelectableItem[] = useMemo( + () => ADVANCED_SETTING_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const networkModeItems: SelectableItem[] = useMemo( + () => NETWORK_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const truncationStrategyItems: SelectableItem[] = useMemo( + () => TRUNCATION_STRATEGY_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const isNameStep = wizard.step === 'name'; + const isModelProviderStep = wizard.step === 'model-provider'; + const isModelIdStep = wizard.step === 'model-id'; + const isApiKeyArnStep = wizard.step === 'api-key-arn'; + const isAdvancedStep = wizard.step === 'advanced'; + const isNetworkModeStep = wizard.step === 'network-mode'; + const isSubnetsStep = wizard.step === 'subnets'; + const isSecurityGroupsStep = wizard.step === 'security-groups'; + const isIdleTimeoutStep = wizard.step === 'idle-timeout'; + const isMaxLifetimeStep = wizard.step === 'max-lifetime'; + const isMaxIterationsStep = wizard.step === 'max-iterations'; + const isMaxTokensStep = wizard.step === 'max-tokens'; + const isTimeoutStep = wizard.step === 'timeout'; + const isTruncationStrategyStep = wizard.step === 'truncation-strategy'; + const isConfirmStep = wizard.step === 'confirm'; + + const modelProviderNav = useListNavigation({ + items: modelProviderItems, + onSelect: item => wizard.setModelProvider(item.id as HarnessModelProvider), + onExit: () => wizard.goBack(), + isActive: isModelProviderStep, + }); + + const bedrockModelNav = useListNavigation({ + items: bedrockModelItems, + onSelect: item => wizard.setModelId(item.id), + onExit: () => wizard.goBack(), + isActive: isModelIdStep && wizard.config.modelProvider === 'bedrock', + }); + + const advancedSettingsNav = useMultiSelectNavigation({ + items: advancedSettingItems, + getId: item => item.id, + onConfirm: ids => wizard.setAdvancedSettings(ids as AdvancedSetting[]), + onExit: () => wizard.goBack(), + isActive: isAdvancedStep, + requireSelection: false, + }); + + const networkModeNav = useListNavigation({ + items: networkModeItems, + onSelect: item => wizard.setNetworkMode(NetworkModeSchema.parse(item.id)), + onExit: () => wizard.goBack(), + isActive: isNetworkModeStep, + }); + + const truncationStrategyNav = useListNavigation({ + items: truncationStrategyItems, + onSelect: item => wizard.setTruncationStrategy(HarnessTruncationStrategySchema.parse(item.id)), + onExit: () => wizard.goBack(), + isActive: isTruncationStrategyStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = isAdvancedStep + ? 'Space toggle · Enter confirm · Esc back' + : isModelProviderStep || isNetworkModeStep || isTruncationStrategyStep || (isModelIdStep && wizard.config.modelProvider === 'bedrock') + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const confirmFields = useMemo(() => { + const fields = [ + { label: 'Name', value: wizard.config.name }, + { label: 'Model Provider', value: wizard.config.modelProvider }, + { label: 'Model ID', value: wizard.config.modelId }, + ]; + + if (wizard.config.apiKeyArn) { + fields.push({ label: 'API Key ARN', value: wizard.config.apiKeyArn }); + } + + if (wizard.config.networkMode) { + fields.push({ label: 'Network Mode', value: wizard.config.networkMode }); + if (wizard.config.networkMode === 'VPC') { + if (wizard.config.subnets) { + fields.push({ label: 'Subnets', value: wizard.config.subnets.join(', ') }); + } + if (wizard.config.securityGroups) { + fields.push({ label: 'Security Groups', value: wizard.config.securityGroups.join(', ') }); + } + } + } + + if (wizard.config.idleTimeout !== undefined) { + fields.push({ label: 'Idle Timeout', value: `${wizard.config.idleTimeout}s` }); + } + + if (wizard.config.maxLifetime !== undefined) { + fields.push({ label: 'Max Lifetime', value: `${wizard.config.maxLifetime}s` }); + } + + if (wizard.config.maxIterations !== undefined) { + fields.push({ label: 'Max Iterations', value: String(wizard.config.maxIterations) }); + } + + if (wizard.config.maxTokens !== undefined) { + fields.push({ label: 'Max Tokens', value: String(wizard.config.maxTokens) }); + } + + if (wizard.config.timeoutSeconds !== undefined) { + fields.push({ label: 'Timeout', value: `${wizard.config.timeoutSeconds}s` }); + } + + if (wizard.config.truncationStrategy) { + fields.push({ label: 'Truncation Strategy', value: wizard.config.truncationStrategy }); + } + + return fields; + }, [wizard.config]); + + return ( + + + {isNameStep && ( + !existingHarnessNames.includes(value) || 'Harness name already exists'} + /> + )} + + {isModelProviderStep && ( + + )} + + {isModelIdStep && wizard.config.modelProvider === 'bedrock' && ( + + )} + + {isModelIdStep && wizard.config.modelProvider !== 'bedrock' && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'Model ID is required')} + /> + )} + + {isApiKeyArnStep && ( + wizard.goBack()} + customValidation={value => isValidArn(value) || ARN_VALIDATION_MESSAGE} + /> + )} + + {isAdvancedStep && ( + + )} + + {isNetworkModeStep && ( + + )} + + {isSubnetsStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'At least one subnet is required for VPC mode')} + /> + )} + + {isSecurityGroupsStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'At least one security group is required for VPC mode')} + /> + )} + + {isIdleTimeoutStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isMaxLifetimeStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isMaxIterationsStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isMaxTokensStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isTimeoutStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isTruncationStrategyStep && ( + + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/harness/index.ts b/src/cli/tui/screens/harness/index.ts new file mode 100644 index 000000000..b2af2e47e --- /dev/null +++ b/src/cli/tui/screens/harness/index.ts @@ -0,0 +1,3 @@ +export { AddHarnessFlow } from './AddHarnessFlow'; +export { AddHarnessScreen } from './AddHarnessScreen'; +export type { AddHarnessConfig, AddHarnessStep } from './types'; diff --git a/src/cli/tui/screens/harness/types.ts b/src/cli/tui/screens/harness/types.ts new file mode 100644 index 000000000..f26d6c7de --- /dev/null +++ b/src/cli/tui/screens/harness/types.ts @@ -0,0 +1,84 @@ +import type { HarnessModelProvider, NetworkMode } from '../../../../schema'; + +export type AddHarnessStep = + | 'name' + | 'model-provider' + | 'model-id' + | 'api-key-arn' + | 'advanced' + | 'network-mode' + | 'subnets' + | 'security-groups' + | 'idle-timeout' + | 'max-lifetime' + | 'max-iterations' + | 'max-tokens' + | 'timeout' + | 'truncation-strategy' + | 'confirm'; + +export interface AddHarnessConfig { + name: string; + modelProvider: HarnessModelProvider; + modelId: string; + apiKeyArn?: string; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + truncationStrategy?: 'sliding_window' | 'summarization'; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; + idleTimeout?: number; + maxLifetime?: number; +} + +export const HARNESS_STEP_LABELS: Record = { + name: 'Name', + 'model-provider': 'Model provider', + 'model-id': 'Model', + 'api-key-arn': 'API key ARN', + advanced: 'Advanced settings', + 'network-mode': 'Network mode', + subnets: 'Subnets', + 'security-groups': 'Security groups', + 'idle-timeout': 'Idle timeout', + 'max-lifetime': 'Max lifetime', + 'max-iterations': 'Max iterations', + 'max-tokens': 'Max tokens', + timeout: 'Timeout', + 'truncation-strategy': 'Truncation', + confirm: 'Confirm', +}; + +export const MODEL_PROVIDER_OPTIONS = [ + { id: 'bedrock' as const, title: 'Amazon Bedrock', description: 'Use models via Amazon Bedrock' }, + { id: 'open_ai' as const, title: 'OpenAI', description: 'Use OpenAI models (requires API key ARN)' }, + { id: 'gemini' as const, title: 'Google Gemini', description: 'Use Google Gemini models (requires API key ARN)' }, +] as const; + +export const BEDROCK_MODEL_OPTIONS = [ + { id: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', title: 'Claude Sonnet 4 (Recommended)' }, + { id: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', title: 'Claude Haiku 3.5' }, + { id: 'us.amazon.nova-pro-v1:0', title: 'Nova Pro' }, + { id: 'us.amazon.nova-lite-v1:0', title: 'Nova Lite' }, +] as const; + +export const TRUNCATION_STRATEGY_OPTIONS = [ + { id: 'sliding_window' as const, title: 'Sliding window', description: 'Keep most recent messages' }, + { id: 'summarization' as const, title: 'Summarization', description: 'Compress older context' }, +] as const; + +export const ADVANCED_SETTING_OPTIONS = [ + { id: 'network', title: 'Network', description: 'VPC configuration' }, + { id: 'lifecycle', title: 'Lifecycle', description: 'Idle timeout and max lifetime' }, + { id: 'execution', title: 'Execution limits', description: 'Iterations, tokens, timeout' }, + { id: 'truncation', title: 'Truncation', description: 'Context management strategy' }, +] as const; + +export type AdvancedSetting = (typeof ADVANCED_SETTING_OPTIONS)[number]['id']; + +export const NETWORK_MODE_OPTIONS = [ + { id: 'PUBLIC' as const, title: 'Public', description: 'Internet-facing' }, + { id: 'VPC' as const, title: 'VPC', description: 'Deploy within a VPC' }, +] as const; diff --git a/src/cli/tui/screens/harness/useAddHarnessWizard.ts b/src/cli/tui/screens/harness/useAddHarnessWizard.ts new file mode 100644 index 000000000..6c736dc50 --- /dev/null +++ b/src/cli/tui/screens/harness/useAddHarnessWizard.ts @@ -0,0 +1,258 @@ +import type { HarnessModelProvider, NetworkMode } from '../../../../schema'; +import type { AddHarnessConfig, AddHarnessStep, AdvancedSetting } from './types'; +import { useCallback, useMemo, useState } from 'react'; + +const SETTING_TO_FIRST_STEP: Record = { + network: 'network-mode', + lifecycle: 'idle-timeout', + execution: 'max-iterations', + truncation: 'truncation-strategy', +}; + +function getFirstAdvancedStep(settings: AdvancedSetting[]): AddHarnessStep | undefined { + for (const setting of ['network', 'lifecycle', 'execution', 'truncation'] as AdvancedSetting[]) { + if (settings.includes(setting)) return SETTING_TO_FIRST_STEP[setting]; + } + return undefined; +} + +function getDefaultConfig(): AddHarnessConfig { + return { + name: '', + modelProvider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }; +} + +export function useAddHarnessWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('name'); + const [advancedSettings, setAdvancedSettingsState] = useState([]); + + const allSteps = useMemo(() => { + const steps: AddHarnessStep[] = ['name', 'model-provider', 'model-id']; + + // Add api-key-arn step for non-bedrock providers + if (config.modelProvider !== 'bedrock') { + steps.push('api-key-arn'); + } + + // Always show advanced settings selection + steps.push('advanced'); + + // Add steps based on advanced settings selections + if (advancedSettings.includes('network')) { + steps.push('network-mode'); + if (config.networkMode === 'VPC') { + steps.push('subnets', 'security-groups'); + } + } + + if (advancedSettings.includes('lifecycle')) { + steps.push('idle-timeout', 'max-lifetime'); + } + + if (advancedSettings.includes('execution')) { + steps.push('max-iterations', 'max-tokens', 'timeout'); + } + + if (advancedSettings.includes('truncation')) { + steps.push('truncation-strategy'); + } + + // Always end with confirm + steps.push('confirm'); + + return steps; + }, [config.modelProvider, config.networkMode, advancedSettings]); + + const currentIndex = allSteps.indexOf(step); + + const goBack = useCallback(() => { + const idx = allSteps.indexOf(step); + const prevStep = allSteps[idx - 1]; + if (prevStep) setStep(prevStep); + }, [allSteps, step]); + + const nextStep = useCallback( + (currentStep: AddHarnessStep): AddHarnessStep | undefined => { + const idx = allSteps.indexOf(currentStep); + return allSteps[idx + 1]; + }, + [allSteps] + ); + + const setName = useCallback( + (name: string) => { + setConfig(c => ({ ...c, name })); + const next = nextStep('name'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setModelProvider = useCallback( + (modelProvider: HarnessModelProvider) => { + setConfig(c => ({ ...c, modelProvider })); + const next = nextStep('model-provider'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setModelId = useCallback( + (modelId: string) => { + setConfig(c => ({ ...c, modelId })); + const next = nextStep('model-id'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setApiKeyArn = useCallback( + (apiKeyArn: string) => { + setConfig(c => ({ ...c, apiKeyArn })); + const next = nextStep('api-key-arn'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setAdvancedSettings = useCallback( + (settings: AdvancedSetting[]) => { + setAdvancedSettingsState(settings); + // Compute next step directly from incoming settings rather than relying + // on allSteps which still reflects the previous (empty) advancedSettings. + const firstAdvancedStep = getFirstAdvancedStep(settings); + setStep(firstAdvancedStep ?? 'confirm'); + }, + [] + ); + + const setNetworkMode = useCallback( + (networkMode: NetworkMode) => { + setConfig(c => ({ ...c, networkMode })); + // Compute next step directly: VPC needs subnets, PUBLIC skips to the + // next advanced section. Can't rely on allSteps since state hasn't + // re-rendered yet (same stale-closure pattern as setAdvancedSettings). + if (networkMode === 'VPC') { + setStep('subnets'); + } else { + const networkIdx = advancedSettings.indexOf('network'); + const remaining = advancedSettings.slice(networkIdx + 1); + const nextAdvanced = getFirstAdvancedStep(remaining); + setStep(nextAdvanced ?? 'confirm'); + } + }, + [advancedSettings] + ); + + const setSubnets = useCallback( + (subnetsStr: string) => { + const subnets = subnetsStr.split(',').map(s => s.trim()).filter(Boolean); + setConfig(c => ({ ...c, subnets })); + const next = nextStep('subnets'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setSecurityGroups = useCallback( + (sgStr: string) => { + const securityGroups = sgStr.split(',').map(s => s.trim()).filter(Boolean); + setConfig(c => ({ ...c, securityGroups })); + const next = nextStep('security-groups'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setIdleTimeout = useCallback( + (idleTimeoutStr: string) => { + const idleTimeout = parseInt(idleTimeoutStr, 10); + setConfig(c => ({ ...c, idleTimeout })); + const next = nextStep('idle-timeout'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMaxLifetime = useCallback( + (maxLifetimeStr: string) => { + const maxLifetime = parseInt(maxLifetimeStr, 10); + setConfig(c => ({ ...c, maxLifetime })); + const next = nextStep('max-lifetime'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMaxIterations = useCallback( + (maxIterationsStr: string) => { + const maxIterations = parseInt(maxIterationsStr, 10); + setConfig(c => ({ ...c, maxIterations })); + const next = nextStep('max-iterations'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMaxTokens = useCallback( + (maxTokensStr: string) => { + const maxTokens = parseInt(maxTokensStr, 10); + setConfig(c => ({ ...c, maxTokens })); + const next = nextStep('max-tokens'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTimeoutSeconds = useCallback( + (timeoutStr: string) => { + const timeoutSeconds = parseInt(timeoutStr, 10); + setConfig(c => ({ ...c, timeoutSeconds })); + const next = nextStep('timeout'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTruncationStrategy = useCallback( + (truncationStrategy: 'sliding_window' | 'summarization') => { + setConfig(c => ({ ...c, truncationStrategy })); + const next = nextStep('truncation-strategy'); + if (next) setStep(next); + }, + [nextStep] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('name'); + setAdvancedSettingsState([]); + }, []); + + return { + config, + step, + steps: allSteps, + currentIndex, + advancedSettings, + goBack, + setName, + setModelProvider, + setModelId, + setApiKeyArn, + setAdvancedSettings, + setNetworkMode, + setSubnets, + setSecurityGroups, + setIdleTimeout, + setMaxLifetime, + setMaxIterations, + setMaxTokens, + setTimeoutSeconds, + setTruncationStrategy, + reset, + }; +} diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index f4f85acd2..4500a68a9 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -83,6 +83,7 @@ interface RemoveFlowProps { /** Initial resource type to start at (for CLI subcommands) */ initialResourceType?: | 'agent' + | 'harness' | 'gateway' | 'gateway-target' | 'memory' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 48b5feb48..5ef7b5c34 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -10,6 +10,9 @@ export const CONFIG_DIR = 'agentcore'; export const APP_DIR = 'app'; export const MCP_APP_SUBDIR = 'mcp'; +// Harnesses directory +export const HARNESS_DIR = 'harnesses'; + // CLI system subdirectory (inside CONFIG_DIR) export const CLI_SYSTEM_DIR = '.cli'; export const CLI_LOGS_DIR = 'logs'; diff --git a/src/lib/schemas/io/__tests__/config-io.test.ts b/src/lib/schemas/io/__tests__/config-io.test.ts index 4b583c502..15f888f8e 100644 --- a/src/lib/schemas/io/__tests__/config-io.test.ts +++ b/src/lib/schemas/io/__tests__/config-io.test.ts @@ -286,6 +286,77 @@ describe('ConfigIO', () => { }); }); + describe('writeHarnessSpec and readHarnessSpec', () => { + it('round-trips valid harness spec', async () => { + const projectDir = join(testDir, `harness-rt-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + + const harnessSpec = { + name: 'testHarness', + model: { + provider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }, + tools: [], + } as any; + + await configIO.writeHarnessSpec('testHarness', harnessSpec); + + const harnessDir = join(agentcoreDir, 'harnesses', 'testHarness'); + const harnessFile = join(harnessDir, 'harness.json'); + expect(existsSync(harnessFile)).toBe(true); + + const readBack = await configIO.readHarnessSpec('testHarness'); + expect(readBack.name).toBe('testHarness'); + expect(readBack.model.provider).toBe('bedrock'); + expect(readBack.model.modelId).toBe('anthropic.claude-3-5-sonnet-20240620-v1:0'); + }); + + it('throws ConfigNotFoundError when harness.json does not exist', async () => { + const projectDir = join(testDir, `harness-missing-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + + await expect(configIO.readHarnessSpec('nonexistent-harness')).rejects.toThrow(ConfigNotFoundError); + }); + + it('throws ConfigValidationError for invalid harness data', async () => { + const projectDir = join(testDir, `harness-invalid-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + + const invalidSpec = { invalid: 'data' } as any; + + await expect(configIO.writeHarnessSpec('bad-harness', invalidSpec)).rejects.toThrow(ConfigValidationError); + }); + + it('throws NoProjectError when no project exists', async () => { + const emptyDir = join(testDir, `empty-harness-${randomUUID()}`); + await mkdir(emptyDir, { recursive: true }); + changeWorkingDir(emptyDir); + + const configIO = new ConfigIO(); + + const validSpec = { + name: 'testHarness', + model: { + provider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }, + tools: [], + } as any; + + await expect(configIO.writeHarnessSpec('testHarness', validSpec)).rejects.toThrow(NoProjectError); + }); + }); + describe('resolveAWSDeploymentTargets region handling (issue #772)', () => { let projectDir: string; let agentcoreDir: string; diff --git a/src/lib/schemas/io/__tests__/path-resolver.test.ts b/src/lib/schemas/io/__tests__/path-resolver.test.ts index cbf9c8288..99beb14d2 100644 --- a/src/lib/schemas/io/__tests__/path-resolver.test.ts +++ b/src/lib/schemas/io/__tests__/path-resolver.test.ts @@ -204,6 +204,21 @@ describe('PathResolver', () => { expect(resolver.getMcpDefsPath()).toBe(join('/base', 'mcp-defs.json')); }); + it('getHarnessesDir returns harnesses directory path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getHarnessesDir()).toBe(join('/base', 'harnesses')); + }); + + it('getHarnessDir returns specific harness directory path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getHarnessDir('myHarness')).toBe(join('/base', 'harnesses', 'myHarness')); + }); + + it('getHarnessConfigPath returns harness config file path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getHarnessConfigPath('myHarness')).toBe(join('/base', 'harnesses', 'myHarness', 'harness.json')); + }); + it('setBaseDir updates the base directory', () => { const resolver = new PathResolver({ baseDir: '/old' }); resolver.setBaseDir('/new'); diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index 423403f90..0ec8a1520 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -1,10 +1,11 @@ -import type { AgentCoreCliMcpDefs, AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../schema'; +import type { AgentCoreCliMcpDefs, AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState, HarnessSpec } from '../../../schema'; import { AgentCoreCliMcpDefsSchema, AgentCoreProjectSpecSchema, AgentCoreRegionSchema, AwsDeploymentTargetsSchema, createValidatedDeployedStateSchema, + HarnessSpecSchema, } from '../../../schema'; import { ConfigNotFoundError, @@ -225,6 +226,22 @@ export class ConfigIO { await this.validateAndWrite(filePath, 'MCP Definitions', AgentCoreCliMcpDefsSchema, data); } + /** + * Read and validate a harness specification file + */ + async readHarnessSpec(harnessName: string): Promise { + const filePath = this.pathResolver.getHarnessConfigPath(harnessName); + return this.readAndValidate(filePath, 'Harness Spec', HarnessSpecSchema); + } + + /** + * Write and validate a harness specification file + */ + async writeHarnessSpec(harnessName: string, data: HarnessSpec): Promise { + const filePath = this.pathResolver.getHarnessConfigPath(harnessName); + await this.validateAndWrite(filePath, 'Harness Spec', HarnessSpecSchema, data); + } + /** * Check if the base directory exists */ diff --git a/src/lib/schemas/io/path-resolver.ts b/src/lib/schemas/io/path-resolver.ts index 9737c4a2b..494ffea37 100644 --- a/src/lib/schemas/io/path-resolver.ts +++ b/src/lib/schemas/io/path-resolver.ts @@ -1,4 +1,4 @@ -import { CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, CONFIG_FILES as _CONFIG_FILES } from '../../constants'; +import { CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, CONFIG_FILES as _CONFIG_FILES, HARNESS_DIR } from '../../constants'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; @@ -202,6 +202,27 @@ export class PathResolver { return join(this.config.baseDir, CONFIG_FILES.MCP_DEFS); } + /** + * Get the path to the harnesses directory (agentcore/harnesses/) + */ + getHarnessesDir(): string { + return join(this.config.baseDir, HARNESS_DIR); + } + + /** + * Get the path to a specific harness directory (agentcore/harnesses//) + */ + getHarnessDir(harnessName: string): string { + return join(this.config.baseDir, HARNESS_DIR, harnessName); + } + + /** + * Get the path to a specific harness config file (agentcore/harnesses//harness.json) + */ + getHarnessConfigPath(harnessName: string): string { + return join(this.config.baseDir, HARNESS_DIR, harnessName, 'harness.json'); + } + /** * Update the base directory */ diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index b34acd267..75897388b 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -44,7 +44,7 @@ export type { RatingScale, } from './primitives/evaluator'; export { BedrockModelIdSchema, isValidBedrockModelId, EvaluatorNameSchema } from './primitives/evaluator'; -export type { HarnessSpec } from './primitives/harness'; +export type { HarnessSpec, HarnessModelProvider } from './primitives/harness'; export { HarnessNameSchema, HarnessSpecSchema, From c87c8af4ce1ad18b8e74d596c3b0caa3b4be3994 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:43:23 -0400 Subject: [PATCH 11/49] feat: add harness invoke support to CLI and TUI (#89) * feat: add harness invoke support to CLI and TUI CLI: --harness flag with override flags (--verbose, --model-id, --tools, --max-iterations, --max-tokens, --harness-timeout, --skills). Auto-infers harness when project has no runtimes. Exec support via --exec with agentRuntimeArn from deployed state. Thinking spinner before first token. TUI: InvokeScreen shows harnesses alongside runtimes in selection, auto-selects single invokable, streams via invokeHarness() with text deltas, tool call labels, and token metadata in gray. Full-screen alt buffer. Ctrl+N for new session. Scroll hint with arrows. Streaming: AWS binary event-stream format (application/vnd.amazon.eventstream) with @smithy/eventstream-codec. Event dispatch via :event-type header. Also: beta/gamma endpoint support for SDK data plane client, agentRuntimeArn field in HarnessDeployedState schema. * feat: fix event stream parser and add inline function tool support Event stream: replaced SSE line parser with AWS binary event-stream codec (@smithy/eventstream-codec). Dispatches on :event-type header instead of payload key matching. Inline function tools: CLI detects tool_use stopReason, shows tool name + input, prompts [Y]es / [N]o / custom response, sends tool result back and loops. TUI shows tool-approval selection panel. Also: added --system-prompt, --allowed-tools, --actor-id override flags. Updated tests to use binary event stream encoding. --- .../aws/__tests__/agentcore-harness.test.ts | 81 ++-- src/cli/aws/agentcore-harness.ts | 201 ++++++---- src/cli/aws/agentcore.ts | 9 + .../invoke/__tests__/validate.test.ts | 20 + src/cli/commands/invoke/action.ts | 361 +++++++++++++++++- src/cli/commands/invoke/command.tsx | 59 ++- src/cli/commands/invoke/types.ts | 21 + src/cli/commands/invoke/validate.ts | 6 + src/cli/tui/screens/invoke/InvokeScreen.tsx | 170 ++++++--- src/cli/tui/screens/invoke/useInvokeFlow.ts | 273 ++++++++++++- src/schema/schemas/deployed-state.ts | 1 + 11 files changed, 1036 insertions(+), 166 deletions(-) diff --git a/src/cli/aws/__tests__/agentcore-harness.test.ts b/src/cli/aws/__tests__/agentcore-harness.test.ts index c6b156d26..7d14d776c 100644 --- a/src/cli/aws/__tests__/agentcore-harness.test.ts +++ b/src/cli/aws/__tests__/agentcore-harness.test.ts @@ -7,6 +7,7 @@ import { listHarnesses, updateHarness, } from '../agentcore-harness.js'; +import { EventStreamCodec } from '@smithy/eventstream-codec'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockRequest, mockRequestRaw } = vi.hoisted(() => ({ @@ -207,11 +208,33 @@ describe('invokeHarness (streaming)', () => { vi.clearAllMocks(); }); - function makeStreamResponse(events: string[]): Response { - const text = events.join('\n') + '\n'; + const toUtf8 = (input: Uint8Array) => new TextDecoder().decode(input); + const fromUtf8 = (input: string) => new TextEncoder().encode(input); + const codec = new EventStreamCodec(toUtf8, fromUtf8); + + function encodeEvent(eventType: string, payload: Record): Uint8Array { + return codec.encode({ + headers: { + ':event-type': { type: 'string', value: eventType }, + ':content-type': { type: 'string', value: 'application/json' }, + ':message-type': { type: 'string', value: 'event' }, + }, + body: fromUtf8(JSON.stringify(payload)), + }); + } + + function makeStreamResponse(frames: Uint8Array[]): Response { + let totalLen = 0; + for (const f of frames) totalLen += f.length; + const combined = new Uint8Array(totalLen); + let off = 0; + for (const f of frames) { + combined.set(f, off); + off += f.length; + } const stream = new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode(text)); + controller.enqueue(combined); controller.close(); }, }); @@ -219,7 +242,7 @@ describe('invokeHarness (streaming)', () => { } it('yields messageStart events', async () => { - mockRequestRaw.mockResolvedValue(makeStreamResponse([JSON.stringify({ messageStart: { role: 'assistant' } })])); + mockRequestRaw.mockResolvedValue(makeStreamResponse([encodeEvent('messageStart', { role: 'assistant' })])); const events = []; for await (const event of invokeHarness({ @@ -238,8 +261,8 @@ describe('invokeHarness (streaming)', () => { it('yields text deltas', async () => { mockRequestRaw.mockResolvedValue( makeStreamResponse([ - JSON.stringify({ contentBlockDelta: { contentBlockIndex: 0, delta: { text: 'Hello' } } }), - JSON.stringify({ contentBlockDelta: { contentBlockIndex: 0, delta: { text: ' world' } } }), + encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: 'Hello' } }), + encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: ' world' } }), ]) ); @@ -269,11 +292,9 @@ describe('invokeHarness (streaming)', () => { it('yields tool use start events', async () => { mockRequestRaw.mockResolvedValue( makeStreamResponse([ - JSON.stringify({ - contentBlockStart: { - contentBlockIndex: 1, - start: { toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' } }, - }, + encodeEvent('contentBlockStart', { + contentBlockIndex: 1, + start: { toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' } }, }), ]) ); @@ -300,7 +321,7 @@ describe('invokeHarness (streaming)', () => { }); it('yields messageStop with stopReason', async () => { - mockRequestRaw.mockResolvedValue(makeStreamResponse([JSON.stringify({ messageStop: { stopReason: 'end_turn' } })])); + mockRequestRaw.mockResolvedValue(makeStreamResponse([encodeEvent('messageStop', { stopReason: 'end_turn' })])); const events = []; for await (const event of invokeHarness({ @@ -318,11 +339,9 @@ describe('invokeHarness (streaming)', () => { it('yields metadata with token usage', async () => { mockRequestRaw.mockResolvedValue( makeStreamResponse([ - JSON.stringify({ - metadata: { - usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, - metrics: { latencyMs: 1200 }, - }, + encodeEvent('metadata', { + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + metrics: { latencyMs: 1200 }, }), ]) ); @@ -344,9 +363,9 @@ describe('invokeHarness (streaming)', () => { }); }); - it('yields error events for server exceptions', async () => { + it('yields error events for exception event types', async () => { mockRequestRaw.mockResolvedValue( - makeStreamResponse([JSON.stringify({ internalServerException: { message: 'Something broke' } })]) + makeStreamResponse([encodeEvent('internalServerException', { message: 'Something broke' })]) ); const events = []; @@ -396,13 +415,17 @@ describe('invokeHarness (streaming)', () => { ); }); - it('skips unparseable lines gracefully', async () => { + it('handles multiple event types in sequence', async () => { mockRequestRaw.mockResolvedValue( makeStreamResponse([ - 'not json at all', - '', - ': comment', - JSON.stringify({ messageStop: { stopReason: 'end_turn' } }), + encodeEvent('messageStart', { role: 'assistant' }), + encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: 'Hi' } }), + encodeEvent('contentBlockStop', { contentBlockIndex: 0 }), + encodeEvent('messageStop', { stopReason: 'end_turn' }), + encodeEvent('metadata', { + usage: { inputTokens: 10, outputTokens: 1, totalTokens: 11 }, + metrics: { latencyMs: 100 }, + }), ]) ); @@ -416,7 +439,13 @@ describe('invokeHarness (streaming)', () => { events.push(event); } - expect(events).toHaveLength(1); - expect(events[0]!.type).toBe('messageStop'); + expect(events).toHaveLength(5); + expect(events.map(e => e.type)).toEqual([ + 'messageStart', + 'contentBlockDelta', + 'contentBlockStop', + 'messageStop', + 'metadata', + ]); }); }); diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts index d070325fb..1c886c892 100644 --- a/src/cli/aws/agentcore-harness.ts +++ b/src/cli/aws/agentcore-harness.ts @@ -408,102 +408,159 @@ export async function* invokeHarness(options: InvokeHarnessOptions): AsyncGenera } async function* parseEventStream(body: ReadableStream): AsyncGenerator { + const { EventStreamCodec } = await import('@smithy/eventstream-codec'); + const codec = new EventStreamCodec(toUtf8, fromUtf8); const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; + let buffer: Uint8Array = new Uint8Array(0); try { while (true) { const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const event = parseLine(line); - if (event) yield event; + buffer = concatBuffers(buffer, new Uint8Array(value)); + + while (buffer.length >= 4) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const totalLength = view.getUint32(0); + if (buffer.length < totalLength) break; + + const frame = buffer.slice(0, totalLength); + buffer = buffer.slice(totalLength); + + try { + const message = codec.decode(frame); + const headers: Record = {}; + for (const [key, val] of Object.entries(message.headers)) { + headers[key] = String(val.value); + } + + if (headers[':message-type'] === 'error') { + yield { + type: 'error', + errorType: headers[':error-code'] ?? 'unknown', + message: headers[':error-message'] ?? 'Unknown error', + }; + continue; + } + + if (headers[':message-type'] === 'exception') { + const exBody = new TextDecoder().decode(message.body); + let msg = exBody; + try { + const parsed = JSON.parse(exBody) as { message?: string }; + msg = parsed.message ?? exBody; + } catch { + // use raw body + } + yield { + type: 'error', + errorType: headers[':exception-type'] ?? 'exception', + message: msg, + }; + continue; + } + + const eventType = headers[':event-type']; + if (!eventType) continue; + + const bodyText = new TextDecoder().decode(message.body); + if (!bodyText) continue; + + const event = parseEventPayload(eventType, bodyText); + if (event) yield event; + } catch { + // skip malformed frames + } } } - - if (buffer.trim()) { - const event = parseLine(buffer); - if (event) yield event; - } } finally { reader.releaseLock(); } } -function parseLine(line: string): HarnessStreamEvent | null { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith(':')) return null; +function toUtf8(input: Uint8Array): string { + return new TextDecoder().decode(input); +} - let payload: Record; - const dataPrefix = 'data: '; - const raw = trimmed.startsWith(dataPrefix) ? trimmed.slice(dataPrefix.length) : trimmed; +function fromUtf8(input: string): Uint8Array { + return new TextEncoder().encode(input); +} +function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +function parseEventPayload(eventType: string, bodyText: string): HarnessStreamEvent | null { + let payload: Record; try { - payload = JSON.parse(raw) as Record; + payload = JSON.parse(bodyText) as Record; } catch { return null; } - if ('messageStart' in payload) { - const ms = payload.messageStart as { role: string }; - return { type: 'messageStart', role: ms.role }; - } - - if ('contentBlockStart' in payload) { - const cbs = payload.contentBlockStart as { contentBlockIndex: number; start: Record }; - return { - type: 'contentBlockStart', - contentBlockIndex: cbs.contentBlockIndex, - start: parseContentBlockStart(cbs.start), - }; - } - - if ('contentBlockDelta' in payload) { - const cbd = payload.contentBlockDelta as { contentBlockIndex: number; delta: Record }; - return { - type: 'contentBlockDelta', - contentBlockIndex: cbd.contentBlockIndex, - delta: parseContentBlockDelta(cbd.delta), - }; - } - - if ('contentBlockStop' in payload) { - const stop = payload.contentBlockStop as { contentBlockIndex: number }; - return { type: 'contentBlockStop', contentBlockIndex: stop.contentBlockIndex }; - } - - if ('messageStop' in payload) { - const ms = payload.messageStop as { stopReason: HarnessStopReason }; - return { type: 'messageStop', stopReason: ms.stopReason }; - } - - if ('metadata' in payload) { - const md = payload.metadata as { usage: TokenUsage; metrics: StreamMetrics }; - return { type: 'metadata', usage: md.usage, metrics: md.metrics }; - } - - if ('internalServerException' in payload) { - const ex = payload.internalServerException as { message?: string }; - return { type: 'error', errorType: 'internalServerException', message: ex.message ?? 'Internal server error' }; - } + switch (eventType) { + case 'messageStart': + return { type: 'messageStart', role: (payload.role as string) ?? 'assistant' }; + + case 'contentBlockStart': { + const start = (payload.start as Record) ?? payload; + return { + type: 'contentBlockStart', + contentBlockIndex: (payload.contentBlockIndex as number) ?? 0, + start: parseContentBlockStart(start), + }; + } - if ('validationException' in payload) { - const ex = payload.validationException as { message?: string }; - return { type: 'error', errorType: 'validationException', message: ex.message ?? 'Validation error' }; - } + case 'contentBlockDelta': { + const delta = (payload.delta as Record) ?? payload; + return { + type: 'contentBlockDelta', + contentBlockIndex: (payload.contentBlockIndex as number) ?? 0, + delta: parseContentBlockDelta(delta), + }; + } - if ('runtimeClientError' in payload) { - const ex = payload.runtimeClientError as { message?: string }; - return { type: 'error', errorType: 'runtimeClientError', message: ex.message ?? 'Runtime client error' }; + case 'contentBlockStop': + return { type: 'contentBlockStop', contentBlockIndex: (payload.contentBlockIndex as number) ?? 0 }; + + case 'messageStop': + return { type: 'messageStop', stopReason: (payload.stopReason as HarnessStopReason) ?? 'end_turn' }; + + case 'metadata': + return { + type: 'metadata', + usage: (payload.usage as TokenUsage) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + metrics: (payload.metrics as StreamMetrics) ?? { latencyMs: 0 }, + }; + + case 'internalServerException': + return { + type: 'error', + errorType: 'internalServerException', + message: (payload.message as string) ?? 'Internal server error', + }; + + case 'validationException': + return { + type: 'error', + errorType: 'validationException', + message: (payload.message as string) ?? 'Validation error', + }; + + case 'runtimeClientError': + return { + type: 'error', + errorType: 'runtimeClientError', + message: (payload.message as string) ?? 'Runtime client error', + }; + + default: + return null; } - - return null; } function parseContentBlockStart(start: Record): ContentBlockStart { diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index a52ee60c3..4c4c43a2c 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -14,10 +14,19 @@ import type { DocumentType } from '@smithy/types'; /** * Create a BedrockAgentCoreClient with optional custom header injection middleware. */ +function resolveDataPlaneEndpoint(region: string): string | undefined { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return undefined; +} + function createAgentCoreClient(region: string, headers?: Record): BedrockAgentCoreClient { + const endpoint = resolveDataPlaneEndpoint(region); const client = new BedrockAgentCoreClient({ region, credentials: getCredentialProvider(), + ...(endpoint && { endpoint }), }); if (headers && Object.keys(headers).length > 0) { diff --git a/src/cli/commands/invoke/__tests__/validate.test.ts b/src/cli/commands/invoke/__tests__/validate.test.ts index 301d15e6c..5361d93ad 100644 --- a/src/cli/commands/invoke/__tests__/validate.test.ts +++ b/src/cli/commands/invoke/__tests__/validate.test.ts @@ -61,4 +61,24 @@ describe('validateInvokeOptions', () => { it('returns valid with exec and prompt', () => { expect(validateInvokeOptions({ exec: true, prompt: 'ls -la' })).toEqual({ valid: true }); }); + + it('returns invalid when --harness and --runtime are both specified', () => { + const result = validateInvokeOptions({ harnessName: 'h1', agentName: 'a1', prompt: 'hi' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('cannot be used together'); + }); + + it('returns invalid when --verbose is used without --harness', () => { + const result = validateInvokeOptions({ verbose: true, prompt: 'hi' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('only supported with --harness'); + }); + + it('returns valid with --harness and prompt', () => { + expect(validateInvokeOptions({ harnessName: 'h1', prompt: 'hi' })).toEqual({ valid: true }); + }); + + it('returns valid with --harness and --verbose', () => { + expect(validateInvokeOptions({ harnessName: 'h1', verbose: true, prompt: 'hi' })).toEqual({ valid: true }); + }); }); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 449738fcd..427256a97 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -9,10 +9,12 @@ import { mcpInitSession, mcpListTools, } from '../../aws'; +import { invokeHarness } from '../../aws/agentcore-harness'; import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; import type { InvokeOptions, InvokeResult } from './types'; +import { randomUUID } from 'node:crypto'; export interface InvokeContext { project: AgentCoreProjectSpec; @@ -56,8 +58,30 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; } + // ── Route to harness or runtime ───────────────────────────────────────── + const harnessEntries = project.harnesses ?? []; + const hasBoth = project.runtimes.length > 0 && harnessEntries.length > 0; + + if (hasBoth && !options.harnessName && !options.agentName) { + const allNames = [ + ...project.runtimes.map(r => `--runtime ${r.name}`), + ...harnessEntries.map(h => `--harness ${h.name}`), + ]; + return { + success: false, + error: `Project has both runtimes and harnesses. Specify one:\n ${allNames.join('\n ')}`, + }; + } + + const isHarnessInvoke = options.harnessName != null || (harnessEntries.length > 0 && project.runtimes.length === 0); + + if (isHarnessInvoke) { + return handleHarnessInvoke(project, targetState, targetConfig, selectedTargetName, options); + } + + // ── Runtime invoke path ──────────────────────────────────────────────── if (project.runtimes.length === 0) { - return { success: false, error: 'No agents defined in configuration' }; + return { success: false, error: 'No runtimes or harnesses defined in configuration' }; } // Resolve agent @@ -385,3 +409,338 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logFilePath: logger.logFilePath, }; } + +// ============================================================================ +// Harness Invoke +// ============================================================================ + +async function handleHarnessInvoke( + project: AgentCoreProjectSpec, + targetState: DeployedState['targets'][string] | undefined, + targetConfig: { region: string; name: string }, + selectedTargetName: string, + options: InvokeOptions +): Promise { + const harnessEntries = project.harnesses ?? []; + + if (harnessEntries.length === 0) { + return { success: false, error: 'No harnesses defined in configuration' }; + } + + // Resolve harness name — explicit flag, or auto-infer if only one + let harnessName = options.harnessName; + if (!harnessName) { + if (harnessEntries.length > 1) { + const names = harnessEntries.map(h => h.name); + return { + success: false, + error: `Multiple harnesses found. Use --harness to specify one: ${names.join(', ')}`, + }; + } + harnessName = harnessEntries[0]!.name; + } + + const harnessEntry = harnessEntries.find(h => h.name === harnessName); + if (!harnessEntry) { + const names = harnessEntries.map(h => h.name); + return { + success: false, + error: `Harness '${harnessName}' not found. Available: ${names.join(', ')}`, + }; + } + + // Get deployed state for this harness + const harnessState = targetState?.resources?.harnesses?.[harnessName]; + if (!harnessState) { + return { + success: false, + error: `Harness '${harnessName}' is not deployed to target '${selectedTargetName}'. Run \`agentcore deploy\` first.`, + }; + } + + // Exec mode: run shell command on harness VM via InvokeAgentRuntimeCommand + if (options.exec) { + if (!harnessState.agentRuntimeArn) { + return { success: false, error: 'Exec requires agentRuntimeArn in deployed state. Re-deploy to populate it.' }; + } + const command = options.prompt; + if (!command) { + return { + success: false, + error: '--exec requires a command. Usage: agentcore invoke --exec --harness "ls -la"', + }; + } + + try { + const result = await executeBashCommand({ + region: targetConfig.region, + runtimeArn: harnessState.agentRuntimeArn, + command, + sessionId: options.sessionId, + timeout: options.timeout, + }); + + let stdout = ''; + let stderr = ''; + let exitCode: number | undefined; + let status: string | undefined; + + for await (const event of result.stream) { + switch (event.type) { + case 'stdout': + if (event.data) { + stdout += event.data; + if (!options.json) process.stdout.write(event.data); + } + break; + case 'stderr': + if (event.data) { + stderr += event.data; + if (!options.json) process.stderr.write(event.data); + } + break; + case 'stop': + exitCode = event.exitCode; + status = event.status; + break; + } + } + + if (options.json) { + return { + success: exitCode === 0, + targetName: selectedTargetName, + response: JSON.stringify({ stdout, stderr, exitCode, status }), + }; + } + + if (exitCode !== 0) { + return { + success: false, + targetName: selectedTargetName, + error: `Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`, + }; + } + + return { success: true, targetName: selectedTargetName }; + } catch (err) { + return { success: false, error: `Exec failed: ${err instanceof Error ? err.message : String(err)}` }; + } + } + + if (!options.prompt) { + return { success: false, error: 'No prompt provided. Usage: agentcore invoke --harness "your prompt"' }; + } + + const sessionId = options.sessionId ?? randomUUID(); + const region = targetConfig.region; + + let fullResponse = ''; + const dim = '\x1b[2m'; + const reset = '\x1b[0m'; + const cyan = '\x1b[36m'; + + const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let spinnerInterval: NodeJS.Timeout | undefined; + let spinnerIdx = 0; + if (!options.json && !options.verbose) { + process.stderr.write(`${dim}${SPINNER[0]} Thinking...${reset}`); + spinnerInterval = setInterval(() => { + spinnerIdx = (spinnerIdx + 1) % SPINNER.length; + process.stderr.write(`\r${dim}${SPINNER[spinnerIdx]} Thinking...${reset}`); + }, 80); + } + + let spinnerCleared = false; + const clearSpinner = () => { + if (spinnerInterval && !spinnerCleared) { + clearInterval(spinnerInterval); + spinnerCleared = true; + process.stderr.write('\r\x1b[K'); + } + }; + + try { + let messages: { role: string; content: Record[] }[] = [ + { role: 'user', content: [{ text: options.prompt }] }, + ]; + + const baseOpts: Partial = {}; + if (options.modelId) baseOpts.model = { bedrockModelConfig: { modelId: options.modelId } }; + if (options.maxIterations != null) baseOpts.maxIterations = options.maxIterations; + if (options.maxTokens != null) baseOpts.maxTokens = options.maxTokens; + if (options.harnessTimeout != null) baseOpts.timeoutSeconds = options.harnessTimeout; + if (options.skills) baseOpts.skills = options.skills.split(',').map(p => ({ path: p.trim() })); + if (options.systemPrompt) baseOpts.systemPrompt = [{ text: options.systemPrompt }]; + if (options.allowedTools) baseOpts.allowedTools = options.allowedTools.split(',').map(t => t.trim()); + if (options.actorId) baseOpts.actorId = options.actorId; + + let continueLoop = true; + while (continueLoop) { + continueLoop = false; + + const stream = invokeHarness({ + region, + harnessArn: harnessState.harnessArn, + runtimeSessionId: sessionId, + messages, + ...baseOpts, + }); + + let pendingToolUseId: string | undefined; + let pendingToolName: string | undefined; + let pendingToolInput = ''; + + for await (const event of stream) { + if (options.verbose) { + clearSpinner(); + console.log(JSON.stringify(event)); + continue; + } + + switch (event.type) { + case 'contentBlockDelta': + if (event.delta.type === 'text') { + clearSpinner(); + fullResponse += event.delta.text; + if (!options.json) { + process.stdout.write(event.delta.text); + } + } else if (event.delta.type === 'toolUse') { + pendingToolInput += event.delta.input; + } + break; + case 'contentBlockStart': + if (event.start.type === 'toolUse') { + pendingToolUseId = event.start.toolUse.toolUseId; + pendingToolName = event.start.toolUse.name; + pendingToolInput = ''; + if (!options.json) { + const serverName = event.start.toolUse.serverName; + const label = serverName ? `${serverName}/${pendingToolName}` : pendingToolName; + process.stderr.write(`\n${dim}🔧 Tool: ${label}${reset}\n`); + } + } + break; + case 'messageStop': + if (event.stopReason === 'tool_use' && pendingToolUseId) { + clearSpinner(); + let inputObj: Record = {}; + try { + inputObj = JSON.parse(pendingToolInput) as Record; + } catch { + // use empty + } + + if (!options.json) { + const yellow = '\x1b[33m'; + const bold = '\x1b[1m'; + process.stderr.write(`\n${yellow}${bold}⏸ Agent requesting: ${pendingToolName}${reset}\n`); + if (Object.keys(inputObj).length > 0) { + process.stderr.write(`${dim}Input: ${JSON.stringify(inputObj, null, 2)}${reset}\n`); + } + } + + const toolResult = await promptForToolResult(pendingToolName ?? 'unknown', inputObj, options.json); + + messages = [ + { + role: 'assistant', + content: [ + { + toolUse: { + toolUseId: pendingToolUseId, + name: pendingToolName ?? 'unknown', + input: inputObj, + }, + }, + ], + }, + { + role: 'user', + content: [ + { + toolResult: { + toolUseId: pendingToolUseId, + content: [{ text: toolResult.response }], + status: toolResult.approved ? 'success' : 'error', + }, + }, + ], + }, + ]; + continueLoop = true; + + if (!options.json) { + const statusIcon = toolResult.approved ? '✅' : '❌'; + process.stderr.write(`${statusIcon} ${toolResult.response}\n`); + } + } else if (!options.json) { + process.stdout.write('\n'); + } + break; + case 'metadata': + if (!options.json) { + const { inputTokens, outputTokens } = event.usage; + const latency = (event.metrics.latencyMs / 1000).toFixed(1); + process.stderr.write( + `\n${dim}⚡ ${cyan}${inputTokens}${dim} in · ${cyan}${outputTokens}${dim} out · ${cyan}${latency}s${reset}\n` + ); + process.stderr.write(`${dim}🔗 Session: ${cyan}${sessionId}${reset}\n`); + } + break; + case 'error': + clearSpinner(); + if (options.json) { + return { success: false, error: `${event.errorType}: ${event.message}` }; + } + process.stderr.write(`\nError: ${event.message}\n`); + break; + } + } + } + + if (options.json) { + return { + success: true, + targetName: selectedTargetName, + response: JSON.stringify({ text: fullResponse, sessionId }), + }; + } + + return { success: true, targetName: selectedTargetName }; + } catch (err) { + clearSpinner(); + return { + success: false, + error: `Harness invoke failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +async function promptForToolResult( + toolName: string, + input: Record, + jsonMode?: boolean +): Promise<{ approved: boolean; response: string }> { + if (jsonMode) { + return { approved: true, response: 'Approved' }; + } + + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + + return new Promise(resolve => { + rl.question('\x1b[33m[Y]es / [N]o / or type a custom response: \x1b[0m', answer => { + rl.close(); + const trimmed = answer.trim().toLowerCase(); + if (trimmed === 'n' || trimmed === 'no' || trimmed === 'deny') { + resolve({ approved: false, response: 'Denied by user' }); + } else if (trimmed === 'y' || trimmed === 'yes' || trimmed === '' || trimmed === 'approve') { + resolve({ approved: true, response: 'Approved' }); + } else { + resolve({ approved: true, response: answer.trim() }); + } + }); + }); +} diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index c808aea53..4e582e08b 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -43,7 +43,11 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { const context = await loadInvokeConfig(); // Show spinner for non-streaming, non-json, non-exec invocations - if (!options.stream && !options.json && !options.exec) { + // Harness invoke always streams directly to stdout, so skip spinner for harness + const isHarness = + options.harnessName != null || + ((context.project.harnesses ?? []).length > 0 && context.project.runtimes.length === 0); + if (!options.stream && !options.json && !options.exec && !isHarness) { spinner = startSpinner('Invoking agent...'); } @@ -110,6 +114,17 @@ export const registerInvoke = (program: Command) => { [] as string[] ) .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]') + .option('--harness ', 'Select specific harness to invoke [non-interactive]') + .option('--verbose', 'Print verbose streaming JSON events (harness only) [non-interactive]') + .option('--model-id ', 'Override model for this invocation (harness only) [non-interactive]') + .option('--tools ', 'Override tools, comma-separated (harness only) [non-interactive]') + .option('--max-iterations ', 'Override max iterations (harness only) [non-interactive]', parseInt) + .option('--max-tokens ', 'Override max tokens (harness only) [non-interactive]', parseInt) + .option('--harness-timeout ', 'Override timeout seconds (harness only) [non-interactive]', parseInt) + .option('--skills ', 'Skills to use, comma-separated paths (harness only) [non-interactive]') + .option('--system-prompt ', 'Override system prompt (harness only) [non-interactive]') + .option('--allowed-tools ', 'Override allowed tools, comma-separated (harness only) [non-interactive]') + .option('--actor-id ', 'Override memory actor ID (harness only) [non-interactive]') .action( async ( positionalPrompt: string | undefined, @@ -127,6 +142,17 @@ export const registerInvoke = (program: Command) => { timeout?: number; header?: string[]; bearerToken?: string; + harness?: string; + verbose?: boolean; + modelId?: string; + tools?: string; + maxIterations?: number; + maxTokens?: number; + harnessTimeout?: number; + skills?: string; + systemPrompt?: string; + allowedTools?: string; + actorId?: string; } ) => { try { @@ -149,11 +175,14 @@ export const registerInvoke = (program: Command) => { cliOptions.runtime || cliOptions.tool || cliOptions.exec || - cliOptions.bearerToken + cliOptions.bearerToken || + cliOptions.harness || + cliOptions.verbose ) { await handleInvokeCLI({ prompt, agentName: cliOptions.runtime, + harnessName: cliOptions.harness, targetName: cliOptions.target ?? 'default', sessionId: cliOptions.sessionId, userId: cliOptions.userId, @@ -165,13 +194,37 @@ export const registerInvoke = (program: Command) => { timeout: cliOptions.timeout, headers, bearerToken: cliOptions.bearerToken, + verbose: cliOptions.verbose, + modelId: cliOptions.modelId, + tools: cliOptions.tools, + maxIterations: cliOptions.maxIterations, + maxTokens: cliOptions.maxTokens, + harnessTimeout: cliOptions.harnessTimeout, + skills: cliOptions.skills, + systemPrompt: cliOptions.systemPrompt, + allowedTools: cliOptions.allowedTools, + actorId: cliOptions.actorId, }); } else { // No CLI options - interactive TUI mode (headers still passed if provided) + const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H'; + const EXIT_ALT_SCREEN = '\x1B[?1049l'; + const SHOW_CURSOR = '\x1B[?25h'; + + process.stdout.write(ENTER_ALT_SCREEN); + + const exitAltScreen = () => { + process.stdout.write(EXIT_ALT_SCREEN); + process.stdout.write(SHOW_CURSOR); + }; + const { waitUntilExit } = render( process.exit(0)} + onExit={() => { + exitAltScreen(); + process.exit(0); + }} initialSessionId={cliOptions.sessionId} initialUserId={cliOptions.userId} initialHeaders={headers} diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 8d8175095..c3fe400d7 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -1,5 +1,6 @@ export interface InvokeOptions { agentName?: string; + harnessName?: string; targetName?: string; prompt?: string; sessionId?: string; @@ -18,6 +19,26 @@ export interface InvokeOptions { headers?: Record; /** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */ bearerToken?: string; + /** Print verbose streaming JSON events instead of formatted text (harness only) */ + verbose?: boolean; + /** Override model ID for this invocation (harness only) */ + modelId?: string; + /** Override tools for this invocation (harness only, comma-separated) */ + tools?: string; + /** Override max iterations (harness only) */ + maxIterations?: number; + /** Override timeout seconds (harness only) */ + harnessTimeout?: number; + /** Override max tokens (harness only) */ + maxTokens?: number; + /** Skills to use (harness only, comma-separated paths) */ + skills?: string; + /** Override system prompt (harness only) */ + systemPrompt?: string; + /** Override allowed tools (harness only, comma-separated) */ + allowedTools?: string; + /** Override memory actor ID (harness only) */ + actorId?: string; } export interface InvokeResult { diff --git a/src/cli/commands/invoke/validate.ts b/src/cli/commands/invoke/validate.ts index dd97241b8..c931acc2a 100644 --- a/src/cli/commands/invoke/validate.ts +++ b/src/cli/commands/invoke/validate.ts @@ -6,6 +6,12 @@ export interface ValidationResult { } export function validateInvokeOptions(options: InvokeOptions): ValidationResult { + if (options.harnessName && options.agentName) { + return { valid: false, error: '--harness and --runtime cannot be used together' }; + } + if (options.verbose && !options.harnessName) { + return { valid: false, error: '--verbose is only supported with --harness' }; + } if (options.exec && !options.prompt) { return { valid: false, error: 'A command is required with --exec. Usage: agentcore invoke --exec "ls -la"' }; } diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index f02e40e4e..3e7482bf6 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -16,7 +16,7 @@ interface InvokeScreenProps { initialBearerToken?: string; } -type Mode = 'select-agent' | 'chat' | 'input' | 'token-input'; +type Mode = 'select-agent' | 'chat' | 'input' | 'token-input' | 'tool-approval'; interface ColoredLine { text: string; @@ -42,6 +42,8 @@ function formatConversation( lines.push({ text: `> ${msg.content}`, color: 'blue' }); } else if (msg.isExec) { lines.push({ text: msg.content }); + } else if (msg.isHint) { + lines.push({ text: msg.content, color: 'gray' }); } else { lines.push({ text: msg.content, color: 'green' }); } @@ -128,15 +130,18 @@ export function InvokeScreen({ bearerToken, tokenFetchState, mcpToolsFetched, + pendingToolApproval, selectAgent, setBearerToken, fetchBearerToken, invoke, execCommand, + respondToTool, newSession, fetchMcpTools, } = useInvokeFlow({ initialSessionId, initialUserId, headers: initialHeaders, initialBearerToken }); const [mode, setMode] = useState('select-agent'); + const [toolApprovalIndex, setToolApprovalIndex] = useState(0); const [isExecInput, setIsExecInput] = useState(false); const [execInputEmpty, setExecInputEmpty] = useState(true); const [scrollOffset, setScrollOffset] = useState(0); @@ -149,13 +154,15 @@ export function InvokeScreen({ const currentAgent = config?.runtimes[selectedAgent]; const isCustomJwt = currentAgent?.authorizerType === 'CUSTOM_JWT'; - // Handle initial prompt - skip agent selection if only one agent + // Handle initial prompt - skip agent selection if only one invokable + const totalInvokables = (config?.runtimes.length ?? 0) + (config?.harnesses.length ?? 0); useEffect(() => { if (config && phase === 'ready') { - if (config.runtimes.length === 1 && mode === 'select-agent') { + if (totalInvokables === 1 && mode === 'select-agent') { const agent = config.runtimes[0]; - const needsTokenScreen = agent?.authorizerType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; - // Defer setState to avoid cascading renders within effect + const isHarness = config.runtimes.length === 0; + const needsTokenScreen = + !isHarness && agent?.authorizerType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; queueMicrotask(() => { setMode(needsTokenScreen ? 'token-input' : 'input'); }); @@ -164,7 +171,7 @@ export function InvokeScreen({ } } } - }, [config, phase, initialPrompt, messages.length, invoke, mode, bearerToken, initialBearerToken]); + }, [config, phase, initialPrompt, messages.length, invoke, mode, bearerToken, initialBearerToken, totalInvokables]); // Auto-exit when prompt was provided upfront and response completes useEffect(() => { @@ -182,12 +189,18 @@ export function InvokeScreen({ } }, [config, selectedAgent, phase, mode, fetchMcpTools]); - // Return to input mode after invoke completes + // Return to input mode after invoke completes, or show tool approval const prevPhaseRef = useRef(phase); useEffect(() => { if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) { queueMicrotask(() => setMode('input')); } + if (phase === 'tool-approval') { + queueMicrotask(() => { + setToolApprovalIndex(0); + setMode('tool-approval'); + }); + } prevPhaseRef.current = phase; }, [phase, initialPrompt]); @@ -251,12 +264,16 @@ export function InvokeScreen({ onExit(); return; } - if (key.upArrow) selectAgent((selectedAgent - 1 + config.runtimes.length) % config.runtimes.length); - if (key.downArrow) selectAgent((selectedAgent + 1) % config.runtimes.length); + if (key.upArrow) selectAgent((selectedAgent - 1 + totalInvokables) % totalInvokables); + if (key.downArrow) selectAgent((selectedAgent + 1) % totalInvokables); if (key.return) { - const chosen = config.runtimes[selectedAgent]; - const needsTokenScreen = chosen?.authorizerType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; - setMode(needsTokenScreen ? 'token-input' : 'input'); + if (selectedAgent >= config.runtimes.length) { + setMode('input'); + } else { + const chosen = config.runtimes[selectedAgent]; + const needsTokenScreen = chosen?.authorizerType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; + setMode(needsTokenScreen ? 'token-input' : 'input'); + } } return; } @@ -268,7 +285,7 @@ export function InvokeScreen({ justCancelledRef.current = false; return; } - if (config.runtimes.length > 1) { + if (totalInvokables > 1) { setMode('select-agent'); return; } @@ -284,8 +301,8 @@ export function InvokeScreen({ return; } - // New session - if (input === 'n' && phase === 'ready') { + // New session (Ctrl+N) + if (key.ctrl && input === 'n' && phase === 'ready') { newSession(); setScrollOffset(0); setUserScrolled(false); @@ -302,6 +319,26 @@ export function InvokeScreen({ { isActive: mode === 'chat' || mode === 'select-agent' } ); + const TOOL_APPROVAL_OPTIONS = ['Yes', 'No', 'Custom response'] as const; + + useInput( + (input, key) => { + if (key.upArrow) setToolApprovalIndex(prev => Math.max(0, prev - 1)); + if (key.downArrow) setToolApprovalIndex(prev => Math.min(TOOL_APPROVAL_OPTIONS.length - 1, prev + 1)); + if (key.return) { + const choice = TOOL_APPROVAL_OPTIONS[toolApprovalIndex]; + if (choice === 'Yes') { + void respondToTool(true, 'Approved'); + } else if (choice === 'No') { + void respondToTool(false, 'Denied by user'); + } else { + setMode('input'); + } + } + }, + { isActive: mode === 'tool-approval' } + ); + // Auto-fetch bearer token to pre-populate the token screen const tokenFetchTriggeredRef = useRef(false); useEffect(() => { @@ -332,7 +369,10 @@ export function InvokeScreen({ return null; } - const agent = config.runtimes[selectedAgent]; + const isHarnessSelected = selectedAgent >= config.runtimes.length; + const agent = isHarnessSelected ? undefined : config.runtimes[selectedAgent]; + const selectedHarness = isHarnessSelected ? config.harnesses[selectedAgent - config.runtimes.length] : undefined; + const selectedName = agent?.name ?? selectedHarness?.name; const traceUrl = mode !== 'select-agent' && agent ? buildTraceConsoleUrl({ @@ -342,36 +382,45 @@ export function InvokeScreen({ agentName: agent.name, }) : undefined; - const agentProtocol = agent?.protocol ?? 'HTTP'; - - const agentItems = config.runtimes.map((a, i) => ({ - id: String(i), - title: a.name, - description: `${a.protocol && a.protocol !== 'HTTP' ? `${a.protocol} · ` : ''}Runtime: ${a.state.runtimeId}`, - })); - - const isMcp = agentProtocol === 'MCP'; + const agentProtocol = isHarnessSelected ? undefined : (agent?.protocol ?? 'HTTP'); + + const agentItems = [ + ...config.runtimes.map((a, i) => ({ + id: String(i), + title: a.name, + description: `${a.protocol && a.protocol !== 'HTTP' ? `${a.protocol} · ` : ''}Agent Runtime`, + })), + ...config.harnesses.map((h, i) => ({ + id: String(config.runtimes.length + i), + title: h.name, + description: 'Harness (managed loop)', + })), + ]; + + const isMcp = !isHarnessSelected && agentProtocol === 'MCP'; // Dynamic help text - const backOrQuit = config.runtimes.length > 1 ? 'Esc back' : 'Esc quit'; + const backOrQuit = totalInvokables > 1 ? 'Esc back' : 'Esc quit'; const helpText = mode === 'select-agent' ? '↑↓ select · Enter confirm · Esc quit' - : mode === 'token-input' - ? 'Enter confirm · Esc skip' - : mode === 'input' - ? isExecInput - ? 'Enter run · Esc cancel · Backspace to exit exec mode' - : isMcp - ? 'Enter send · Esc cancel · "list" to refresh tools · ! exec mode' - : 'Enter send · Esc cancel · ! exec mode' - : phase === 'invoking' - ? '↑↓ scroll' - : messages.length > 0 - ? `↑↓ scroll · Enter invoke · N new session · ${backOrQuit}` + : mode === 'tool-approval' + ? '↑↓ navigate · Enter select' + : mode === 'token-input' + ? 'Enter confirm · Esc skip' + : mode === 'input' + ? isExecInput + ? 'Enter run · Esc cancel · Backspace to exit exec mode' : isMcp - ? `Enter to call a tool · N new session · ${backOrQuit}` - : `Enter to send a message · ${backOrQuit}`; + ? 'Enter send · Esc cancel · "list" to refresh tools · ! exec mode' + : 'Enter send · Esc cancel · ! exec mode' + : phase === 'invoking' + ? '↑↓ scroll' + : messages.length > 0 + ? `↑↓ scroll · Enter invoke · Ctrl+N new session · ${backOrQuit}` + : isMcp + ? `Enter to call a tool · Ctrl+N new session · ${backOrQuit}` + : `Enter to send a message · ${backOrQuit}`; const headerContent = ( @@ -381,11 +430,11 @@ export function InvokeScreen({ {mode !== 'select-agent' && ( - Agent: - {agent?.name} + {isHarnessSelected ? 'Harness: ' : 'Agent: '} + {selectedName} )} - {mode !== 'select-agent' && agentProtocol !== 'HTTP' && ( + {mode !== 'select-agent' && !isHarnessSelected && agentProtocol && agentProtocol !== 'HTTP' && ( Protocol: {agentProtocol} @@ -422,7 +471,7 @@ export function InvokeScreen({ )} {traceUrl && Note: Traces may take 2-3 minutes to appear in CloudWatch} - {mode !== 'select-agent' && agent?.networkMode === 'VPC' && ( + {mode !== 'select-agent' && !isHarnessSelected && agent?.networkMode === 'VPC' && ( This agent uses VPC network mode. Ensure your VPC endpoints are configured for invocation. @@ -473,7 +522,9 @@ export function InvokeScreen({ {/* Scroll indicator */} {needsScroll && ( - [{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}] + {effectiveOffset > 0 ? '▲ ' : ' '} + ↑↓ scroll + {effectiveOffset < maxScroll ? ' ▼' : ' '} )} @@ -515,7 +566,26 @@ export function InvokeScreen({ )} )} - {mode === 'input' && phase === 'ready' && ( + {mode === 'tool-approval' && pendingToolApproval && ( + + + + {Object.keys(pendingToolApproval.input).length > 0 && ( + Input: {JSON.stringify(pendingToolApproval.input, null, 2)} + )} + + {TOOL_APPROVAL_OPTIONS.map((opt, idx) => ( + + {idx === toolApprovalIndex ? '❯ ' : ' '} + {opt} + + ))} + + + + + )} + {mode === 'input' && (phase === 'ready' || phase === 'tool-approval') && ( <> {isExecInput ? '! ' : '> '} @@ -547,11 +617,15 @@ export function InvokeScreen({ if (trimmed) { setMode('chat'); setUserScrolled(false); - if (isExecInput) { + if (pendingToolApproval) { + void respondToTool(true, trimmed); + } else if (isExecInput) { void execCommand(trimmed); } else { void invoke(text); } + } else if (pendingToolApproval) { + setMode('tool-approval'); } else if (!isExecInput) { setMode('chat'); } @@ -559,6 +633,8 @@ export function InvokeScreen({ onCancel={() => { if (isExecInput) { setIsExecInput(false); + } else if (pendingToolApproval) { + setMode('tool-approval'); } else { justCancelledRef.current = true; setMode('chat'); diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 2aeb083c1..5bb4c5dd9 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -2,6 +2,7 @@ import { ConfigIO } from '../../../../lib'; import type { AgentCoreDeployedState, AwsDeploymentTarget, + HarnessDeployedState, ModelProvider, NetworkMode, ProtocolMode, @@ -17,6 +18,7 @@ import { mcpCallTool, mcpListTools, } from '../../../aws'; +import { invokeHarness } from '../../../aws/agentcore-harness'; import { getErrorMessage } from '../../../errors'; import { InvokeLogger } from '../../../logging'; import { formatMcpToolList } from '../../../operations/dev/utils'; @@ -33,6 +35,10 @@ export interface InvokeConfig { protocol?: ProtocolMode; authorizerType?: RuntimeAuthorizerType; }[]; + harnesses: { + name: string; + state: HarnessDeployedState; + }[]; target: AwsDeploymentTarget; targetName: string; projectName: string; @@ -48,8 +54,16 @@ export interface InvokeFlowOptions { export type TokenFetchState = 'idle' | 'fetching' | 'fetched' | 'error'; +export interface PendingToolApproval { + toolUseId: string; + toolName: string; + input: Record; + harnessArn: string; + sessionId: string; +} + export interface InvokeFlowState { - phase: 'loading' | 'ready' | 'invoking' | 'error'; + phase: 'loading' | 'ready' | 'invoking' | 'tool-approval' | 'error'; config: InvokeConfig | null; selectedAgent: number; messages: { role: 'user' | 'assistant'; content: string; isHint?: boolean }[]; @@ -63,19 +77,22 @@ export interface InvokeFlowState { tokenExpiresIn: number | undefined; mcpTools: McpToolDef[]; mcpToolsFetched: boolean; + pendingToolApproval: PendingToolApproval | null; selectAgent: (index: number) => void; setUserId: (id: string) => void; setBearerToken: (token: string) => void; fetchBearerToken: () => Promise; invoke: (prompt: string) => Promise; execCommand: (command: string) => Promise; + respondToTool: (approved: boolean, response: string) => Promise; newSession: () => void; fetchMcpTools: () => Promise; } export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState { const { initialSessionId, initialUserId, headers, initialBearerToken } = options; - const [phase, setPhase] = useState<'loading' | 'ready' | 'invoking' | 'error'>('loading'); + const [phase, setPhase] = useState<'loading' | 'ready' | 'invoking' | 'tool-approval' | 'error'>('loading'); + const [pendingToolApproval, setPendingToolApproval] = useState(null); const [config, setConfig] = useState(null); const [selectedAgent, setSelectedAgent] = useState(0); const [messages, setMessages] = useState< @@ -139,13 +156,20 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState }); } - if (runtimes.length === 0) { - setError('No deployed agents found. Run `agentcore deploy` first.'); + const harnesses: InvokeConfig['harnesses'] = []; + for (const harness of project.harnesses ?? []) { + const state = targetState?.resources?.harnesses?.[harness.name]; + if (!state) continue; + harnesses.push({ name: harness.name, state }); + } + + if (runtimes.length === 0 && harnesses.length === 0) { + setError('No deployed agents or harnesses found. Run `agentcore deploy` first.'); setPhase('error'); return; } - setConfig({ runtimes, target: targetConfig, targetName, projectName: project.name }); + setConfig({ runtimes, harnesses, target: targetConfig, targetName, projectName: project.name }); // Initialize session ID - always generate fresh unless explicitly provided if (initialSessionId) { @@ -231,20 +255,135 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState // Track current streaming content to avoid stale closure issues const streamingContentRef = useRef(''); + const streamHarnessInvoke = useCallback( + async ( + region: string, + harnessArn: string, + runtimeSessionId: string, + harnessMessages: { role: string; content: Record[] }[] + ) => { + let pendingToolUseId: string | undefined; + let pendingToolName: string | undefined; + let pendingToolInput = ''; + + try { + const stream = invokeHarness({ + region, + harnessArn, + runtimeSessionId, + messages: harnessMessages, + }); + + for await (const event of stream) { + switch (event.type) { + case 'contentBlockDelta': + if (event.delta.type === 'text') { + streamingContentRef.current += event.delta.text; + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: currentContent }; + } + return updated; + }); + } else if (event.delta.type === 'toolUse') { + pendingToolInput += event.delta.input; + } + break; + case 'contentBlockStart': + if (event.start.type === 'toolUse') { + pendingToolUseId = event.start.toolUse.toolUseId; + pendingToolName = event.start.toolUse.name; + pendingToolInput = ''; + const serverName = event.start.toolUse.serverName; + const label = serverName ? `${serverName}/${pendingToolName}` : pendingToolName; + streamingContentRef.current += `\n🔧 Tool: ${label}\n`; + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: currentContent }; + } + return updated; + }); + } + break; + case 'messageStop': + if (event.stopReason === 'tool_use' && pendingToolUseId) { + let inputObj: Record = {}; + try { + inputObj = JSON.parse(pendingToolInput) as Record; + } catch { + // use empty + } + setPendingToolApproval({ + toolUseId: pendingToolUseId, + toolName: pendingToolName ?? 'unknown', + input: inputObj, + harnessArn, + sessionId: runtimeSessionId, + }); + setPhase('tool-approval'); + return; + } + break; + case 'metadata': { + const { inputTokens, outputTokens } = event.usage; + const latency = (event.metrics.latencyMs / 1000).toFixed(1); + const metaLine = `⚡ ${inputTokens} in · ${outputTokens} out · ${latency}s`; + setMessages(prev => [...prev, { role: 'assistant', content: metaLine, isHint: true }]); + break; + } + case 'error': + streamingContentRef.current += `\nError: ${event.message}`; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: streamingContentRef.current }; + } + return updated; + }); + break; + } + } + + setPhase('ready'); + } catch (err) { + const errMsg = getErrorMessage(err); + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: `Error: ${errMsg}` }; + } + return updated; + }); + setPhase('ready'); + } + }, + [] + ); + const invoke = useCallback( async (prompt: string) => { if (!config || phase === 'invoking') return; + const isHarness = selectedAgent >= config.runtimes.length; const agent = config.runtimes[selectedAgent]; - if (!agent) return; + if (!agent && !isHarness) return; - const isMcp = agent.protocol === 'MCP'; + const isMcp = !isHarness && agent?.protocol === 'MCP'; // Create logger on first invoke or if agent changed if (!loggerRef.current) { + const harnessForLog = isHarness ? config.harnesses[selectedAgent - config.runtimes.length] : undefined; loggerRef.current = new InvokeLogger({ - agentName: agent.name, - runtimeArn: agent.state.runtimeArn, + agentName: agent?.name ?? harnessForLog?.name ?? 'harness', + runtimeArn: agent?.state.runtimeArn ?? harnessForLog?.state.harnessArn ?? '', region: config.target.region, sessionId: sessionId ?? undefined, }); @@ -313,7 +452,23 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState return; } - // HTTP / A2A: streaming invoke + if (isHarness) { + const harnessIdx = selectedAgent - config.runtimes.length; + const harness = config.harnesses[harnessIdx]; + if (!harness) return; + + setMessages(prev => [...prev, { role: 'user', content: prompt }, { role: 'assistant', content: '' }]); + setPhase('invoking'); + streamingContentRef.current = ''; + + await streamHarnessInvoke(config.target.region, harness.state.harnessArn, sessionId ?? generateSessionId(), [ + { role: 'user', content: [{ text: prompt }] }, + ]); + return; + } + + // HTTP / A2A: streaming invoke (agent is guaranteed defined here — harness path returned above) + if (!agent) return; const isA2A = agent.protocol === 'A2A'; setMessages(prev => [...prev, { role: 'user', content: prompt }, { role: 'assistant', content: '' }]); setPhase('invoking'); @@ -374,21 +529,59 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState setPhase('ready'); } }, - [config, selectedAgent, phase, sessionId, userId, headers, bearerToken, fetchMcpTools, getMcpInvokeOptions] + [ + config, + selectedAgent, + phase, + sessionId, + userId, + headers, + bearerToken, + fetchMcpTools, + getMcpInvokeOptions, + streamHarnessInvoke, + ] ); const execCommand = useCallback( async (command: string) => { if (!config || phase === 'invoking') return; - const agent = config.runtimes[selectedAgent]; - if (!agent) return; + const isHarnessExec = selectedAgent >= config.runtimes.length; + const agent = isHarnessExec ? undefined : config.runtimes[selectedAgent]; + if (!agent && !isHarnessExec) return; + + // For harness exec, we need the underlying agentRuntimeArn + let execRuntimeArn: string | undefined; + let execName: string; + if (isHarnessExec) { + const harnessIdx = selectedAgent - config.runtimes.length; + const harness = config.harnesses[harnessIdx]; + if (!harness) return; + execRuntimeArn = harness.state.agentRuntimeArn; + execName = harness.name; + if (!execRuntimeArn) { + setMessages(prev => [ + ...prev, + { role: 'user', content: `! ${command}`, isExec: true }, + { + role: 'assistant', + content: 'Exec requires agentRuntimeArn in deployed state. Re-deploy to populate it.', + isExec: true, + }, + ]); + return; + } + } else { + execRuntimeArn = agent!.state.runtimeArn; + execName = agent!.name; + } - // Create logger on first invoke or if agent changed + // Create logger on first exec or if agent changed if (!loggerRef.current) { loggerRef.current = new InvokeLogger({ - agentName: agent.name, - runtimeArn: agent.state.runtimeArn, + agentName: execName, + runtimeArn: execRuntimeArn, region: config.target.region, sessionId: sessionId ?? undefined, }); @@ -410,7 +603,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState try { const result = await executeBashCommand({ region: config.target.region, - runtimeArn: agent.state.runtimeArn, + runtimeArn: execRuntimeArn, command, sessionId: sessionId ?? undefined, headers, @@ -466,10 +659,54 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState [config, selectedAgent, phase, sessionId, userId, headers, bearerToken] ); + const respondToTool = useCallback( + async (approved: boolean, response: string) => { + if (!pendingToolApproval || !config) return; + + const { toolUseId, harnessArn, sessionId: runtimeSessionId } = pendingToolApproval; + + const statusIcon = approved ? '✅' : '❌'; + setMessages(prev => [...prev, { role: 'assistant', content: `${statusIcon} ${response}`, isHint: true }]); + setPendingToolApproval(null); + setPhase('invoking'); + streamingContentRef.current = ''; + setMessages(prev => [...prev, { role: 'assistant', content: '' }]); + + await streamHarnessInvoke(config.target.region, harnessArn, runtimeSessionId, [ + { + role: 'assistant', + content: [ + { + toolUse: { + toolUseId, + name: pendingToolApproval.toolName, + input: pendingToolApproval.input, + }, + }, + ], + }, + { + role: 'user', + content: [ + { + toolResult: { + toolUseId, + content: [{ text: response }], + status: approved ? 'success' : 'error', + }, + }, + ], + }, + ]); + }, + [config, pendingToolApproval, streamHarnessInvoke] + ); + const newSession = useCallback(() => { const newId = generateSessionId(); setSessionId(newId); setMessages([]); + setPendingToolApproval(null); // Reset MCP session mcpSessionIdRef.current = undefined; setMcpTools([]); @@ -492,12 +729,14 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState tokenExpiresIn, mcpTools, mcpToolsFetched, + pendingToolApproval, selectAgent: setSelectedAgent, setUserId, setBearerToken, fetchBearerToken, invoke, execCommand, + respondToTool, newSession, fetchMcpTools, }; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 65d85fb58..f3b660acc 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -142,6 +142,7 @@ export const HarnessDeployedStateSchema = z.object({ harnessArn: z.string().min(1), roleArn: z.string().min(1), status: z.string().min(1), + agentRuntimeArn: z.string().optional(), memoryArn: z.string().optional(), }); From 9eab540f8333f47a38b0d6ce3289b6956e482f32 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 17 Apr 2026 11:24:56 -0400 Subject: [PATCH 12/49] feat(schema): add harnesses[] registry to AgentCoreProjectSpec --- .../commands/logs/__tests__/action.test.ts | 4 ++++ src/cli/commands/remove/command.tsx | 1 + .../__tests__/checks-extended.test.ts | 10 +++++++++ .../agent/generate/write-agent-to-project.ts | 1 + .../operations/dev/__tests__/config.test.ts | 21 +++++++++++++++++++ .../__tests__/GatewayPrimitive.test.ts | 1 + .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/project.ts | 2 +- .../__tests__/agentcore-project.test.ts | 4 ++-- src/schema/schemas/agentcore-project.ts | 21 +++++++------------ 10 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 039acfb67..4c8b40162 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -60,6 +60,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, deployedState: { targets: { @@ -121,6 +122,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); @@ -162,6 +164,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, deployedState: { targets: { @@ -213,6 +216,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index deb1a9274..54f3c158f 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -34,6 +34,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(true); }); @@ -78,6 +79,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -94,6 +96,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -121,6 +124,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -146,6 +150,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -162,6 +167,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -195,6 +201,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -262,6 +269,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -282,6 +290,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -310,6 +319,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 26750d279..8c05a52ea 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -72,6 +72,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index b6967ac6e..c7a681553 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -21,6 +21,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -48,6 +49,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -75,6 +77,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -108,6 +111,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -136,6 +140,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -162,6 +167,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -191,6 +197,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; // No configRoot provided @@ -220,6 +227,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -249,6 +257,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -277,6 +286,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -305,6 +315,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -333,6 +344,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -361,6 +373,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -390,6 +403,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -432,6 +446,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -450,6 +465,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -473,6 +489,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -499,6 +516,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -533,6 +551,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -561,6 +580,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -597,6 +617,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 4c4c66402..17a3f0f2c 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -13,6 +13,7 @@ const defaultProject: AgentCoreProjectSpec = { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 3fce5148f..2eca7c1a7 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -93,6 +93,7 @@ describe('createManagedOAuthCredential', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/project.ts b/src/cli/project.ts index c66d650cb..b5cc89bfb 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,7 +18,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - + harnesses: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index b272d76e2..2b77e50b4 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -583,11 +583,11 @@ describe('AgentCoreProjectSpecSchema', () => { expect(result.success).toBe(true); }); - it('omits harnesses when not provided', () => { + it('defaults harnesses to empty array', () => { const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); expect(result.success).toBe(true); if (result.success) { - expect(result.data.harnesses).toBeUndefined(); + expect(result.data.harnesses).toEqual([]); } }); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 75897388b..953e90ec8 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -334,20 +334,13 @@ export const AgentCoreProjectSpecSchema = z harnesses: z .array(HarnessRegistryEntrySchema) - .optional() - .superRefine((harnesses, ctx) => { - if (!harnesses) return; - const seen = new Set(); - for (const harness of harnesses) { - if (seen.has(harness.name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Duplicate harness name: ${harness.name}`, - }); - } - seen.add(harness.name); - } - }), + .default([]) + .superRefine( + uniqueBy( + harness => harness.name, + name => `Duplicate harness name: ${name}` + ) + ), }) .strict() .superRefine((spec, ctx) => { From 902c95c4cdbaef2cc409c19e9a5d9f626ade0a97 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 17 Apr 2026 11:31:07 -0400 Subject: [PATCH 13/49] chore(schema): regenerate JSON schema with harnesses registry --- schemas/agentcore.schema.v1.json | 1 + 1 file changed, 1 insertion(+) diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 3f661a66b..aad1ac96f 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -1785,6 +1785,7 @@ } }, "harnesses": { + "default": [], "type": "array", "items": { "type": "object", From 08e4f2fe45b24dd7c4b61b9bb4f36a493d268853 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:34:39 -0400 Subject: [PATCH 14/49] feat: add harness validation to validate command (#91) Validates each harness registered in agentcore.json by loading its harness.json via ConfigIO.readHarnessSpec(). Also cross-references memory.name against the project's defined memories to catch dangling references early. --- .../validate/__tests__/action.test.ts | 134 ++++++++++++++++++ src/cli/commands/validate/action.ts | 25 +++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/validate/__tests__/action.test.ts b/src/cli/commands/validate/__tests__/action.test.ts index d6774f252..a27894f64 100644 --- a/src/cli/commands/validate/__tests__/action.test.ts +++ b/src/cli/commands/validate/__tests__/action.test.ts @@ -5,12 +5,14 @@ const { mockReadProjectSpec, mockReadAWSDeploymentTargets, mockReadDeployedState, + mockReadHarnessSpec, mockConfigExists, mockFindConfigRoot, } = vi.hoisted(() => ({ mockReadProjectSpec: vi.fn(), mockReadAWSDeploymentTargets: vi.fn(), mockReadDeployedState: vi.fn(), + mockReadHarnessSpec: vi.fn(), mockConfigExists: vi.fn(), mockFindConfigRoot: vi.fn(), })); @@ -54,6 +56,7 @@ vi.mock('../../../../lib/index.js', () => { readProjectSpec = mockReadProjectSpec; readAWSDeploymentTargets = mockReadAWSDeploymentTargets; readDeployedState = mockReadDeployedState; + readHarnessSpec = mockReadHarnessSpec; configExists = mockConfigExists; }, ConfigValidationError, @@ -204,4 +207,135 @@ describe('handleValidate', () => { expect(result.success).toBe(false); expect(result.error).toBe('string error'); }); + + describe('harness validation', () => { + const projectWithHarness = { + name: 'Test', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [{ name: 'myMemory', eventExpiryDuration: 30, strategies: [] }], + harnesses: [{ name: 'myHarness', path: './harnesses/myHarness' }], + }; + + const validHarnessSpec = { + name: 'myHarness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0' }, + tools: [], + skills: [], + }; + + it('validates harness specs when project has harnesses', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue(projectWithHarness); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockReadHarnessSpec.mockResolvedValue(validHarnessSpec); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + expect(mockReadHarnessSpec).toHaveBeenCalledWith('myHarness'); + }); + + it('returns error when harness spec is invalid', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue(projectWithHarness); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockReadHarnessSpec.mockRejectedValue(new Error('invalid harness')); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('invalid harness'); + }); + + it('returns error when harness references non-existent memory', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue(projectWithHarness); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockReadHarnessSpec.mockResolvedValue({ + ...validHarnessSpec, + memory: { name: 'nonExistent' }, + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('references memory "nonExistent"'); + expect(result.error).toContain('not defined in the project'); + }); + + it('passes when harness references existing memory', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue(projectWithHarness); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockReadHarnessSpec.mockResolvedValue({ + ...validHarnessSpec, + memory: { name: 'myMemory' }, + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + }); + + it('passes when harness memory uses arn instead of name', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue(projectWithHarness); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockReadHarnessSpec.mockResolvedValue({ + ...validHarnessSpec, + memory: { arn: 'arn:aws:bedrock:us-east-1:123456789012:memory/abc' }, + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + }); + + it('skips harness validation when project has no harnesses', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue({ + name: 'Test', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + expect(mockReadHarnessSpec).not.toHaveBeenCalled(); + }); + + it('validates multiple harnesses and fails on first error', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue({ + ...projectWithHarness, + harnesses: [ + { name: 'harness1', path: './harnesses/harness1' }, + { name: 'harness2', path: './harnesses/harness2' }, + ], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockReadHarnessSpec.mockImplementation((name: string) => { + if (name === 'harness1') return Promise.resolve(validHarnessSpec); + return Promise.reject(new Error('harness2 invalid')); + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('harness2 invalid'); + }); + }); }); diff --git a/src/cli/commands/validate/action.ts b/src/cli/commands/validate/action.ts index 572f5cf7d..410056750 100644 --- a/src/cli/commands/validate/action.ts +++ b/src/cli/commands/validate/action.ts @@ -7,6 +7,7 @@ import { NoProjectError, findConfigRoot, } from '../../../lib'; +import type { AgentCoreProjectSpec } from '../../../schema'; export interface ValidateOptions { directory?: string; @@ -36,8 +37,9 @@ export async function handleValidate(options: ValidateOptions): Promise m.name)); + + for (const harness of harnesses) { + const harnessFile = `harnesses/${harness.name}/harness.json`; + + try { + const harnessSpec = await configIO.readHarnessSpec(harness.name); + + if (harnessSpec.memory?.name && !memoryNames.has(harnessSpec.memory.name)) { + return { + success: false, + error: `Harness "${harness.name}" references memory "${harnessSpec.memory.name}" which is not defined in the project`, + }; + } + } catch (err) { + return { success: false, error: formatError(err, harnessFile) }; + } + } + return { success: true }; } From 10d8dd8f0a84ef271a098099667d9d031095c1c4 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:26:19 -0400 Subject: [PATCH 15/49] feat: add container support to harness create/add (#93) * feat(schema): add harnesses[] registry to AgentCoreProjectSpec * chore(schema): regenerate JSON schema with harnesses registry * feat: add container support to harness create/add Add --container flag to `agentcore add harness` and `agentcore create` that accepts either an ECR container URI or a Dockerfile path (inferred automatically). In the TUI wizard, a new "Container" step lets users choose between None, Container URI, or Dockerfile. When a Dockerfile path is provided, it is copied into app// and the filename is stored in the harness spec. Container URI is passed directly to the harness spec for the deploy mapper. Schema validation now rejects specs with both containerUri and dockerfile set (mutually exclusive). * feat: pass container fields to CDK for ECR permissions, fix harnesses schema to .optional() Thread containerUri and hasDockerfile from harness specs through the vended CDK project so the L3 construct can conditionally add ECR pull permissions. Also reverts harnesses field from .default([]) back to .optional() to match the base branch. * fix: address PR review comments for container support - Add file-existence check before copyFile with user-friendly error - Resolve relative Dockerfile paths against project root, not cwd - Remove bare '/' from parseContainerFlag heuristic (false positive for registry URIs like /my-org/image:latest) - Add .min(1) to containerUri/dockerfile schema fields - Use explicit undefined checks in superRefine mutual exclusion validator --- .../assets.snapshot.test.ts.snap | 10 +- src/assets/cdk/bin/cdk.ts | 10 +- src/cli/commands/create/command.tsx | 36 ++- src/cli/commands/create/harness-action.ts | 6 +- src/cli/commands/create/harness-validate.ts | 27 ++- src/cli/commands/create/types.ts | 1 + .../commands/logs/__tests__/action.test.ts | 4 + src/cli/commands/remove/command.tsx | 1 + .../__tests__/checks-extended.test.ts | 10 + .../agent/generate/write-agent-to-project.ts | 1 + .../operations/dev/__tests__/config.test.ts | 21 ++ src/cli/primitives/HarnessPrimitive.ts | 54 ++++- .../__tests__/GatewayPrimitive.test.ts | 1 + .../__tests__/HarnessPrimitive.test.ts | 213 ++++++++++++++---- .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/project.ts | 2 +- src/cli/tui/screens/create/useCreateFlow.ts | 2 + .../tui/screens/harness/AddHarnessFlow.tsx | 17 +- .../tui/screens/harness/AddHarnessScreen.tsx | 71 +++++- src/cli/tui/screens/harness/types.ts | 17 ++ .../screens/harness/useAddHarnessWizard.ts | 68 ++++-- .../__tests__/agentcore-project.test.ts | 2 +- .../primitives/__tests__/harness.test.ts | 12 + src/schema/schemas/primitives/harness.ts | 11 +- 24 files changed, 498 insertions(+), 100 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index cba8ba9a3..6a8fec1cd 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -102,7 +102,13 @@ async function main() { // Read harness configs for role creation. // Harness fields may not yet be on the AgentCoreProjectSpec type from @aws/agentcore-cdk, // so we read them dynamically via specAny (same pattern as gateways above). - const harnessConfigs: { name: string; executionRoleArn?: string; memoryName?: string }[] = []; + const harnessConfigs: { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + }[] = []; for (const entry of specAny.harnesses ?? []) { const harnessPath = path.resolve(configRoot, entry.path, 'harness.json'); try { @@ -111,6 +117,8 @@ async function main() { name: entry.name, executionRoleArn: harnessSpec.executionRoleArn, memoryName: harnessSpec.memory?.name, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, }); } catch (err) { throw new Error( diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 4f1b990d5..88735036f 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -57,7 +57,13 @@ async function main() { // Read harness configs for role creation. // Harness fields may not yet be on the AgentCoreProjectSpec type from @aws/agentcore-cdk, // so we read them dynamically via specAny (same pattern as gateways above). - const harnessConfigs: { name: string; executionRoleArn?: string; memoryName?: string }[] = []; + const harnessConfigs: { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + }[] = []; for (const entry of specAny.harnesses ?? []) { const harnessPath = path.resolve(configRoot, entry.path, 'harness.json'); try { @@ -66,6 +72,8 @@ async function main() { name: entry.name, executionRoleArn: harnessSpec.executionRoleArn, memoryName: harnessSpec.memory?.name, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, }); } catch (err) { throw new Error( diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 97b30cded..a8d1d5b24 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -10,6 +10,7 @@ import type { } from '../../../schema'; import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { getErrorMessage } from '../../errors'; +import { harnessPrimitive } from '../../primitives/registry'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { CreateScreen } from '../../tui/screens/create'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; @@ -25,7 +26,14 @@ import { Text, render } from 'ink'; const AGENT_PATH_FLAGS = ['framework', 'language', 'build', 'protocol', 'type', 'agentId', 'agentAliasId'] as const; /** Flags that are harness-only */ -const HARNESS_ONLY_FLAGS = ['modelId', 'apiKeyArn', 'maxIterations', 'maxTokens', 'timeout', 'truncationStrategy'] as const; +const HARNESS_ONLY_FLAGS = [ + 'modelId', + 'apiKeyArn', + 'maxIterations', + 'maxTokens', + 'timeout', + 'truncationStrategy', +] as const; /** Determines if the agent path should be taken based on provided flags */ function isAgentPath(options: CreateOptions): boolean { @@ -121,7 +129,12 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); const validation = validateCreateHarnessOptions( - { name: options.name, modelProvider: options.modelProvider, modelId: options.modelId, apiKeyArn: options.apiKeyArn }, + { + name: options.name, + modelProvider: options.modelProvider, + modelId: options.modelId, + apiKeyArn: options.apiKeyArn, + }, cwd ); if (!validation.valid) { @@ -143,17 +156,21 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { else if (status === 'error') console.log(`\x1b[31m[error]${reset} ${step}`); }; - const provider = (options.modelProvider - ? normalizeHarnessModelProvider(options.modelProvider) - : 'bedrock') as HarnessModelProvider; + const provider = ( + options.modelProvider ? normalizeHarnessModelProvider(options.modelProvider) : 'bedrock' + ) as HarnessModelProvider; const modelId = options.modelId ?? 'us.anthropic.claude-sonnet-4-5-20250514-v1:0'; + const containerOption = harnessPrimitive.parseContainerFlag(options.container); + const result = await createProjectWithHarness({ name: options.name!, cwd, modelProvider: provider, modelId, apiKeyArn: options.apiKeyArn, + containerUri: containerOption.containerUri, + dockerfilePath: containerOption.dockerfilePath, skipMemory: options.harnessMemory === false, maxIterations: options.maxIterations ? Number(options.maxIterations) : undefined, maxTokens: options.maxTokens ? Number(options.maxTokens) : undefined, @@ -318,7 +335,11 @@ export const registerCreate = (program: Command) => { .option('--max-iterations ', 'Max agent loop iterations (harness) [non-interactive]') .option('--max-tokens ', 'Max tokens per iteration (harness) [non-interactive]') .option('--timeout ', 'Max execution duration in seconds (harness) [non-interactive]') - .option('--truncation-strategy ', 'Truncation strategy: sliding_window or summarization (harness) [non-interactive]') + .option( + '--truncation-strategy ', + 'Truncation strategy: sliding_window or summarization (harness) [non-interactive]' + ) + .option('--container ', 'Container image URI or Dockerfile path (harness) [non-interactive]') .action(async options => { try { // Any flag triggers non-interactive CLI mode @@ -367,7 +388,8 @@ export const registerCreate = (program: Command) => { // Conflict detection: agent-path flags + harness-only flags if (isAgentPath(opts) && hasHarnessOnlyFlags(opts)) { - const error = 'Cannot mix agent-path flags (--framework, --language, etc.) with harness-only flags (--model-id, --max-iterations, etc.)'; + const error = + 'Cannot mix agent-path flags (--framework, --language, etc.) with harness-only flags (--model-id, --max-iterations, etc.)'; if (opts.json) { console.log(JSON.stringify({ success: false, error })); } else { diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts index 85afc5e91..bd1b4f1f3 100644 --- a/src/cli/commands/create/harness-action.ts +++ b/src/cli/commands/create/harness-action.ts @@ -2,7 +2,7 @@ import { CONFIG_DIR } from '../../../lib'; import type { HarnessModelProvider, NetworkMode } from '../../../schema'; import { getErrorMessage } from '../../errors'; import { harnessPrimitive } from '../../primitives/registry'; -import { createProject, type ProgressCallback } from './action'; +import { type ProgressCallback, createProject } from './action'; import type { CreateResult } from './types'; import { join } from 'path'; @@ -13,6 +13,8 @@ export interface CreateHarnessProjectOptions { modelId: string; apiKeyArn?: string; skipMemory?: boolean; + containerUri?: string; + dockerfilePath?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; @@ -53,6 +55,8 @@ export async function createProjectWithHarness(options: CreateHarnessProjectOpti modelProvider: options.modelProvider, modelId: options.modelId, apiKeyArn: options.apiKeyArn, + containerUri: options.containerUri, + dockerfilePath: options.dockerfilePath, skipMemory: options.skipMemory, maxIterations: options.maxIterations, maxTokens: options.maxTokens, diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts index 583ff54fd..46246aa6b 100644 --- a/src/cli/commands/create/harness-validate.ts +++ b/src/cli/commands/create/harness-validate.ts @@ -1,13 +1,12 @@ import { HarnessNameSchema } from '../../../schema'; import { validateFolderNotExists } from './validate'; -import { existsSync } from 'fs'; -import { join } from 'path'; export interface CreateHarnessCliOptions { name?: string; modelProvider?: string; modelId?: string; apiKeyArn?: string; + container?: string; noMemory?: boolean; maxIterations?: string; maxTokens?: string; @@ -31,12 +30,12 @@ export interface ValidationResult { } const MODEL_PROVIDER_MAPPING: Record = { - 'bedrock': 'bedrock', - 'Bedrock': 'bedrock', - 'open_ai': 'open_ai', - 'OpenAI': 'open_ai', - 'gemini': 'gemini', - 'Gemini': 'gemini', + bedrock: 'bedrock', + Bedrock: 'bedrock', + open_ai: 'open_ai', + OpenAI: 'open_ai', + gemini: 'gemini', + Gemini: 'gemini', }; export function normalizeHarnessModelProvider(raw: string): string | undefined { @@ -61,16 +60,16 @@ export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, c if (options.modelProvider) { const normalized = normalizeHarnessModelProvider(options.modelProvider); if (!normalized) { - return { valid: false, error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, or gemini` }; + return { + valid: false, + error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, or gemini`, + }; } options.modelProvider = normalized; - } else { - options.modelProvider = 'bedrock'; } + options.modelProvider ??= 'bedrock'; - if (!options.modelId) { - options.modelId = 'us.anthropic.claude-sonnet-4-5-20250514-v1:0'; - } + options.modelId ??= 'us.anthropic.claude-sonnet-4-5-20250514-v1:0'; if (options.modelProvider !== 'bedrock' && !options.apiKeyArn) { return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` }; diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index 63430dd54..2f0112dc8 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -26,6 +26,7 @@ export interface CreateOptions extends VpcOptions { // Harness-specific modelId?: string; apiKeyArn?: string; + container?: string; harnessMemory?: boolean; maxIterations?: string; maxTokens?: string; diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 039acfb67..4c8b40162 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -60,6 +60,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, deployedState: { targets: { @@ -121,6 +122,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); @@ -162,6 +164,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, deployedState: { targets: { @@ -213,6 +216,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index deb1a9274..54f3c158f 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -34,6 +34,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(true); }); @@ -78,6 +79,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -94,6 +96,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -121,6 +124,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -146,6 +150,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -162,6 +167,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -195,6 +201,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -262,6 +269,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -282,6 +290,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -310,6 +319,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 26750d279..8c05a52ea 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -72,6 +72,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index b6967ac6e..c7a681553 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -21,6 +21,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -48,6 +49,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -75,6 +77,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -108,6 +111,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -136,6 +140,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -162,6 +167,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -191,6 +197,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; // No configRoot provided @@ -220,6 +227,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -249,6 +257,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -277,6 +286,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -305,6 +315,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -333,6 +344,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -361,6 +373,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -390,6 +403,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -432,6 +446,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -450,6 +465,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -473,6 +489,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -499,6 +516,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -533,6 +551,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -561,6 +580,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -597,6 +617,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts index 987ff5bb1..bd10f6614 100644 --- a/src/cli/primitives/HarnessPrimitive.ts +++ b/src/cli/primitives/HarnessPrimitive.ts @@ -1,6 +1,6 @@ -import { ConfigIO, findConfigRoot } from '../../lib'; +import { APP_DIR, ConfigIO, findConfigRoot } from '../../lib'; import type { HarnessModelProvider, HarnessSpec, NetworkMode } from '../../schema'; -import { HarnessNameSchema, HarnessSpecSchema } from '../../schema'; +import { HarnessSpecSchema } from '../../schema'; import { deleteHarness } from '../aws/agentcore-harness'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; @@ -8,8 +8,8 @@ import { DEFAULT_MEMORY_EXPIRY_DAYS } from '../tui/screens/generate/defaults'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; import type { Command } from '@commander-js/extra-typings'; -import { rm, writeFile } from 'fs/promises'; -import { dirname, join } from 'path'; +import { access, copyFile, mkdir, rm, writeFile } from 'fs/promises'; +import { basename, dirname, isAbsolute, join, resolve } from 'path'; export interface AddHarnessOptions { name: string; @@ -18,6 +18,8 @@ export interface AddHarnessOptions { apiKeyArn?: string; systemPrompt?: string; skipMemory?: boolean; + containerUri?: string; + dockerfilePath?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; @@ -52,6 +54,24 @@ export class HarnessPrimitive extends BasePrimitive', 'Model provider: bedrock, open_ai, gemini') .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)') .option('--api-key-arn ', 'API key ARN for non-Bedrock providers') + .option('--container ', 'Container image URI or path to a Dockerfile') .option('--no-memory', 'Skip auto-creating memory') .option('--max-iterations ', 'Max iterations', parseInt) .option('--max-tokens ', 'Max tokens', parseInt) @@ -226,6 +249,7 @@ export class HarnessPrimitive extends BasePrimitive ({ diff --git a/src/cli/primitives/__tests__/HarnessPrimitive.test.ts b/src/cli/primitives/__tests__/HarnessPrimitive.test.ts index c57ddabba..dd10aef09 100644 --- a/src/cli/primitives/__tests__/HarnessPrimitive.test.ts +++ b/src/cli/primitives/__tests__/HarnessPrimitive.test.ts @@ -1,6 +1,6 @@ import type { AgentCoreProjectSpec, NetworkMode } from '../../../schema'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HarnessPrimitive } from '../HarnessPrimitive'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockReadProjectSpec = vi.fn(); const mockWriteProjectSpec = vi.fn(); @@ -8,6 +8,7 @@ const mockWriteHarnessSpec = vi.fn(); const mockGetHarnessDir = vi.fn().mockReturnValue('/tmp/test/agentcore/harnesses/test'); vi.mock('../../../lib', () => ({ + APP_DIR: 'app', ConfigIO: class { readProjectSpec = mockReadProjectSpec; writeProjectSpec = mockWriteProjectSpec; @@ -21,9 +22,11 @@ vi.mock('../../../lib', () => ({ })); vi.mock('fs/promises', () => ({ + access: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), })); const baseProject: AgentCoreProjectSpec = { @@ -65,13 +68,16 @@ describe('HarnessPrimitive', () => { expect(result.harnessName).toBe('testHarness'); } - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - name: 'testHarness', - model: { - provider: 'bedrock', - modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', - }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + name: 'testHarness', + model: { + provider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }, + }) + ); expect(mockWriteProjectSpec).toHaveBeenCalledWith( expect.objectContaining({ @@ -101,9 +107,12 @@ describe('HarnessPrimitive', () => { }) ); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - memory: { name: 'testHarnessMemory' }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + memory: { name: 'testHarnessMemory' }, + }) + ); }); it('sets memory reference in harness spec', async () => { @@ -115,9 +124,12 @@ describe('HarnessPrimitive', () => { modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - memory: { name: 'testHarnessMemory' }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + memory: { name: 'testHarnessMemory' }, + }) + ); }); it('rejects duplicate harness name', async () => { @@ -170,11 +182,14 @@ describe('HarnessPrimitive', () => { timeoutSeconds: 300, }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - maxIterations: 10, - maxTokens: 4096, - timeoutSeconds: 300, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + maxIterations: 10, + maxTokens: 4096, + timeoutSeconds: 300, + }) + ); }); it('includes truncation strategy in harness spec', async () => { @@ -187,11 +202,14 @@ describe('HarnessPrimitive', () => { truncationStrategy: 'sliding_window', }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - truncation: { - strategy: 'sliding_window', - }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + truncation: { + strategy: 'sliding_window', + }, + }) + ); }); it('includes network config for VPC mode', async () => { @@ -206,13 +224,16 @@ describe('HarnessPrimitive', () => { securityGroups: ['sg-789'], }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - networkMode: 'VPC', - networkConfig: { - subnets: ['subnet-123', 'subnet-456'], - securityGroups: ['sg-789'], - }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + networkMode: 'VPC', + networkConfig: { + subnets: ['subnet-123', 'subnet-456'], + securityGroups: ['sg-789'], + }, + }) + ); }); it('includes lifecycle config when provided', async () => { @@ -226,12 +247,15 @@ describe('HarnessPrimitive', () => { maxLifetime: 3600, }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - lifecycleConfig: { - idleRuntimeSessionTimeout: 600, - maxLifetime: 3600, - }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + lifecycleConfig: { + idleRuntimeSessionTimeout: 600, + maxLifetime: 3600, + }, + }) + ); }); it('includes API key ARN for non-Bedrock providers', async () => { @@ -244,13 +268,16 @@ describe('HarnessPrimitive', () => { apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - model: { - provider: 'open_ai', - modelId: 'gpt-4', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', - }, - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + model: { + provider: 'open_ai', + modelId: 'gpt-4', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + }, + }) + ); }); it('includes system prompt when provided', async () => { @@ -263,9 +290,101 @@ describe('HarnessPrimitive', () => { systemPrompt: 'You are a helpful assistant.', }); - expect(mockWriteHarnessSpec).toHaveBeenCalledWith('testHarness', expect.objectContaining({ - systemPrompt: 'You are a helpful assistant.', - })); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + systemPrompt: 'You are a helpful assistant.', + }) + ); + }); + + it('sets containerUri in harness spec', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + containerUri: '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-harness:latest', + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + containerUri: '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-harness:latest', + }) + ); + }); + + it('copies Dockerfile and sets dockerfile field in harness spec', async () => { + const { copyFile, mkdir } = await import('fs/promises'); + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + dockerfilePath: '/some/path/Dockerfile', + }); + + expect(mkdir).toHaveBeenCalledWith(expect.stringContaining('app/testHarness'), { recursive: true }); + expect(copyFile).toHaveBeenCalledWith( + '/some/path/Dockerfile', + expect.stringContaining('app/testHarness/Dockerfile') + ); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + dockerfile: 'Dockerfile', + }) + ); + }); + + it('returns error when Dockerfile does not exist', async () => { + const { access } = await import('fs/promises'); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + const result = await primitive.add({ + name: 'testHarness', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + dockerfilePath: '/nonexistent/Dockerfile', + }); + + expect(result.success).toBe(false); + expect(!result.success && result.error).toContain('Dockerfile not found at'); + }); + }); + + describe('parseContainerFlag()', () => { + it('returns empty for undefined', () => { + expect(primitive.parseContainerFlag(undefined)).toEqual({}); + }); + + it('detects Dockerfile paths ending with Dockerfile', () => { + expect(primitive.parseContainerFlag('./Dockerfile')).toEqual({ dockerfilePath: './Dockerfile' }); + expect(primitive.parseContainerFlag('/abs/path/Dockerfile')).toEqual({ dockerfilePath: '/abs/path/Dockerfile' }); + }); + + it('detects .dockerfile extension', () => { + expect(primitive.parseContainerFlag('custom.dockerfile')).toEqual({ dockerfilePath: 'custom.dockerfile' }); + }); + + it('detects relative paths', () => { + expect(primitive.parseContainerFlag('./my-image/Dockerfile.prod')).toEqual({ + dockerfilePath: './my-image/Dockerfile.prod', + }); + expect(primitive.parseContainerFlag('../Dockerfile')).toEqual({ dockerfilePath: '../Dockerfile' }); + }); + + it('treats ECR URIs as containerUri', () => { + const uri = '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-harness:latest'; + expect(primitive.parseContainerFlag(uri)).toEqual({ containerUri: uri }); + }); + + it('treats non-path strings as containerUri', () => { + expect(primitive.parseContainerFlag('my-harness:latest')).toEqual({ containerUri: 'my-harness:latest' }); }); }); diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 3fce5148f..2eca7c1a7 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -93,6 +93,7 @@ describe('createManagedOAuthCredential', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/project.ts b/src/cli/project.ts index c66d650cb..b5cc89bfb 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,7 +18,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], - + harnesses: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index de9ba234c..d13f73d44 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -491,6 +491,8 @@ export function useCreateFlow(cwd: string): CreateFlowState { modelProvider: addHarnessConfig.modelProvider, modelId: addHarnessConfig.modelId, apiKeyArn: addHarnessConfig.apiKeyArn, + containerUri: addHarnessConfig.containerUri, + dockerfilePath: addHarnessConfig.dockerfilePath, maxIterations: addHarnessConfig.maxIterations, maxTokens: addHarnessConfig.maxTokens, timeoutSeconds: addHarnessConfig.timeoutSeconds, diff --git a/src/cli/tui/screens/harness/AddHarnessFlow.tsx b/src/cli/tui/screens/harness/AddHarnessFlow.tsx index 2eb0fa28a..91fb15025 100644 --- a/src/cli/tui/screens/harness/AddHarnessFlow.tsx +++ b/src/cli/tui/screens/harness/AddHarnessFlow.tsx @@ -51,6 +51,8 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on modelProvider: config.modelProvider, modelId: config.modelId, apiKeyArn: config.apiKeyArn, + containerUri: config.containerUri, + dockerfilePath: config.dockerfilePath, maxIterations: config.maxIterations, maxTokens: config.maxTokens, timeoutSeconds: config.timeoutSeconds, @@ -67,7 +69,12 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on } // Deploy harness to AWS - setFlow({ name: 'create-success', harnessName: config.name, loading: true, loadingMessage: 'Deploying harness to AWS...' }); + setFlow({ + name: 'create-success', + harnessName: config.name, + loading: true, + loadingMessage: 'Deploying harness to AWS...', + }); try { const { handleDeploy } = await import('../../../commands/deploy/actions'); const deployResult = await handleDeploy({ target: 'default', autoConfirm: true }); @@ -86,7 +93,13 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on }, []); if (flow.name === 'create-wizard') { - return ; + return ( + void handleCreateComplete(config)} + onExit={onBack} + /> + ); } if (flow.name === 'create-success') { diff --git a/src/cli/tui/screens/harness/AddHarnessScreen.tsx b/src/cli/tui/screens/harness/AddHarnessScreen.tsx index 6aa8dac33..613fb44bb 100644 --- a/src/cli/tui/screens/harness/AddHarnessScreen.tsx +++ b/src/cli/tui/screens/harness/AddHarnessScreen.tsx @@ -15,10 +15,11 @@ import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddHarnessConfig, AdvancedSetting } from './types'; +import type { AddHarnessConfig, AdvancedSetting, ContainerMode } from './types'; import { ADVANCED_SETTING_OPTIONS, BEDROCK_MODEL_OPTIONS, + CONTAINER_MODE_OPTIONS, HARNESS_STEP_LABELS, MODEL_PROVIDER_OPTIONS, NETWORK_MODE_OPTIONS, @@ -46,6 +47,11 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A [] ); + const containerModeItems: SelectableItem[] = useMemo( + () => CONTAINER_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + const advancedSettingItems: SelectableItem[] = useMemo( () => ADVANCED_SETTING_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), [] @@ -65,6 +71,9 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isModelProviderStep = wizard.step === 'model-provider'; const isModelIdStep = wizard.step === 'model-id'; const isApiKeyArnStep = wizard.step === 'api-key-arn'; + const isContainerStep = wizard.step === 'container'; + const isContainerUriStep = wizard.step === 'container-uri'; + const isContainerDockerfileStep = wizard.step === 'container-dockerfile'; const isAdvancedStep = wizard.step === 'advanced'; const isNetworkModeStep = wizard.step === 'network-mode'; const isSubnetsStep = wizard.step === 'subnets'; @@ -91,6 +100,13 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A isActive: isModelIdStep && wizard.config.modelProvider === 'bedrock', }); + const containerModeNav = useListNavigation({ + items: containerModeItems, + onSelect: item => wizard.setContainerMode(item.id as ContainerMode), + onExit: () => wizard.goBack(), + isActive: isContainerStep, + }); + const advancedSettingsNav = useMultiSelectNavigation({ items: advancedSettingItems, getId: item => item.id, @@ -123,7 +139,11 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const helpText = isAdvancedStep ? 'Space toggle · Enter confirm · Esc back' - : isModelProviderStep || isNetworkModeStep || isTruncationStrategyStep || (isModelIdStep && wizard.config.modelProvider === 'bedrock') + : isModelProviderStep || + isContainerStep || + isNetworkModeStep || + isTruncationStrategyStep || + (isModelIdStep && wizard.config.modelProvider === 'bedrock') ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -142,6 +162,14 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A fields.push({ label: 'API Key ARN', value: wizard.config.apiKeyArn }); } + if (wizard.config.containerUri) { + fields.push({ label: 'Container URI', value: wizard.config.containerUri }); + } + + if (wizard.config.dockerfilePath) { + fields.push({ label: 'Dockerfile', value: wizard.config.dockerfilePath }); + } + if (wizard.config.networkMode) { fields.push({ label: 'Network Mode', value: wizard.config.networkMode }); if (wizard.config.networkMode === 'VPC') { @@ -242,6 +270,37 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A /> )} + {isContainerStep && ( + + )} + + {isContainerUriStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'Container URI is required')} + /> + )} + + {isContainerDockerfileStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'Dockerfile path is required')} + /> + )} + {isAdvancedStep && ( wizard.goBack()} - customValidation={value => (value.trim().length > 0 ? true : 'At least one subnet is required for VPC mode')} + customValidation={value => + value.trim().length > 0 ? true : 'At least one subnet is required for VPC mode' + } /> )} @@ -279,7 +340,9 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A initialValue="" onSubmit={wizard.setSecurityGroups} onCancel={() => wizard.goBack()} - customValidation={value => (value.trim().length > 0 ? true : 'At least one security group is required for VPC mode')} + customValidation={value => + value.trim().length > 0 ? true : 'At least one security group is required for VPC mode' + } /> )} diff --git a/src/cli/tui/screens/harness/types.ts b/src/cli/tui/screens/harness/types.ts index f26d6c7de..d90bb7741 100644 --- a/src/cli/tui/screens/harness/types.ts +++ b/src/cli/tui/screens/harness/types.ts @@ -1,10 +1,15 @@ import type { HarnessModelProvider, NetworkMode } from '../../../../schema'; +export type ContainerMode = 'none' | 'uri' | 'dockerfile'; + export type AddHarnessStep = | 'name' | 'model-provider' | 'model-id' | 'api-key-arn' + | 'container' + | 'container-uri' + | 'container-dockerfile' | 'advanced' | 'network-mode' | 'subnets' @@ -22,6 +27,9 @@ export interface AddHarnessConfig { modelProvider: HarnessModelProvider; modelId: string; apiKeyArn?: string; + containerMode?: ContainerMode; + containerUri?: string; + dockerfilePath?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; @@ -38,6 +46,9 @@ export const HARNESS_STEP_LABELS: Record = { 'model-provider': 'Model provider', 'model-id': 'Model', 'api-key-arn': 'API key ARN', + container: 'Container', + 'container-uri': 'Container URI', + 'container-dockerfile': 'Dockerfile path', advanced: 'Advanced settings', 'network-mode': 'Network mode', subnets: 'Subnets', @@ -78,6 +89,12 @@ export const ADVANCED_SETTING_OPTIONS = [ export type AdvancedSetting = (typeof ADVANCED_SETTING_OPTIONS)[number]['id']; +export const CONTAINER_MODE_OPTIONS = [ + { id: 'none' as const, title: 'None', description: 'Use the default managed runtime' }, + { id: 'uri' as const, title: 'Container URI', description: 'Use a pre-built container image (ECR URI)' }, + { id: 'dockerfile' as const, title: 'Dockerfile', description: 'Build from a Dockerfile' }, +] as const; + export const NETWORK_MODE_OPTIONS = [ { id: 'PUBLIC' as const, title: 'Public', description: 'Internet-facing' }, { id: 'VPC' as const, title: 'VPC', description: 'Deploy within a VPC' }, diff --git a/src/cli/tui/screens/harness/useAddHarnessWizard.ts b/src/cli/tui/screens/harness/useAddHarnessWizard.ts index 6c736dc50..2c5d35dfb 100644 --- a/src/cli/tui/screens/harness/useAddHarnessWizard.ts +++ b/src/cli/tui/screens/harness/useAddHarnessWizard.ts @@ -1,5 +1,5 @@ import type { HarnessModelProvider, NetworkMode } from '../../../../schema'; -import type { AddHarnessConfig, AddHarnessStep, AdvancedSetting } from './types'; +import type { AddHarnessConfig, AddHarnessStep, AdvancedSetting, ContainerMode } from './types'; import { useCallback, useMemo, useState } from 'react'; const SETTING_TO_FIRST_STEP: Record = { @@ -32,12 +32,17 @@ export function useAddHarnessWizard() { const allSteps = useMemo(() => { const steps: AddHarnessStep[] = ['name', 'model-provider', 'model-id']; - // Add api-key-arn step for non-bedrock providers if (config.modelProvider !== 'bedrock') { steps.push('api-key-arn'); } - // Always show advanced settings selection + steps.push('container'); + if (config.containerMode === 'uri') { + steps.push('container-uri'); + } else if (config.containerMode === 'dockerfile') { + steps.push('container-dockerfile'); + } + steps.push('advanced'); // Add steps based on advanced settings selections @@ -64,7 +69,7 @@ export function useAddHarnessWizard() { steps.push('confirm'); return steps; - }, [config.modelProvider, config.networkMode, advancedSettings]); + }, [config.modelProvider, config.containerMode, config.networkMode, advancedSettings]); const currentIndex = allSteps.indexOf(step); @@ -118,17 +123,43 @@ export function useAddHarnessWizard() { [nextStep] ); - const setAdvancedSettings = useCallback( - (settings: AdvancedSetting[]) => { - setAdvancedSettingsState(settings); - // Compute next step directly from incoming settings rather than relying - // on allSteps which still reflects the previous (empty) advancedSettings. - const firstAdvancedStep = getFirstAdvancedStep(settings); - setStep(firstAdvancedStep ?? 'confirm'); + const setContainerMode = useCallback((containerMode: ContainerMode) => { + setConfig(c => ({ ...c, containerMode, containerUri: undefined, dockerfilePath: undefined })); + if (containerMode === 'uri') { + setStep('container-uri'); + } else if (containerMode === 'dockerfile') { + setStep('container-dockerfile'); + } else { + setStep('advanced'); + } + }, []); + + const setContainerUri = useCallback( + (containerUri: string) => { + setConfig(c => ({ ...c, containerUri })); + const next = nextStep('container-uri'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setDockerfilePath = useCallback( + (dockerfilePath: string) => { + setConfig(c => ({ ...c, dockerfilePath })); + const next = nextStep('container-dockerfile'); + if (next) setStep(next); }, - [] + [nextStep] ); + const setAdvancedSettings = useCallback((settings: AdvancedSetting[]) => { + setAdvancedSettingsState(settings); + // Compute next step directly from incoming settings rather than relying + // on allSteps which still reflects the previous (empty) advancedSettings. + const firstAdvancedStep = getFirstAdvancedStep(settings); + setStep(firstAdvancedStep ?? 'confirm'); + }, []); + const setNetworkMode = useCallback( (networkMode: NetworkMode) => { setConfig(c => ({ ...c, networkMode })); @@ -149,7 +180,10 @@ export function useAddHarnessWizard() { const setSubnets = useCallback( (subnetsStr: string) => { - const subnets = subnetsStr.split(',').map(s => s.trim()).filter(Boolean); + const subnets = subnetsStr + .split(',') + .map(s => s.trim()) + .filter(Boolean); setConfig(c => ({ ...c, subnets })); const next = nextStep('subnets'); if (next) setStep(next); @@ -159,7 +193,10 @@ export function useAddHarnessWizard() { const setSecurityGroups = useCallback( (sgStr: string) => { - const securityGroups = sgStr.split(',').map(s => s.trim()).filter(Boolean); + const securityGroups = sgStr + .split(',') + .map(s => s.trim()) + .filter(Boolean); setConfig(c => ({ ...c, securityGroups })); const next = nextStep('security-groups'); if (next) setStep(next); @@ -243,6 +280,9 @@ export function useAddHarnessWizard() { setModelProvider, setModelId, setApiKeyArn, + setContainerMode, + setContainerUri, + setDockerfilePath, setAdvancedSettings, setNetworkMode, setSubnets, diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index b272d76e2..9792f1498 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -583,7 +583,7 @@ describe('AgentCoreProjectSpecSchema', () => { expect(result.success).toBe(true); }); - it('omits harnesses when not provided', () => { + it('harnesses is undefined when not provided', () => { const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); expect(result.success).toBe(true); if (result.success) { diff --git a/src/schema/schemas/primitives/__tests__/harness.test.ts b/src/schema/schemas/primitives/__tests__/harness.test.ts index de68d6ac0..d32b5db7d 100644 --- a/src/schema/schemas/primitives/__tests__/harness.test.ts +++ b/src/schema/schemas/primitives/__tests__/harness.test.ts @@ -491,6 +491,18 @@ describe('HarnessSpecSchema', () => { expect(result.success).toBe(true); }); + it('rejects containerUri and dockerfile together', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + containerUri: '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agent:latest', + dockerfile: 'Dockerfile', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('mutually exclusive'))).toBe(true); + } + }); + it('accepts harness with VPC network config', () => { const result = HarnessSpecSchema.safeParse({ ...minimalHarness, diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts index da275e178..603cc7e9f 100644 --- a/src/schema/schemas/primitives/harness.ts +++ b/src/schema/schemas/primitives/harness.ts @@ -226,8 +226,8 @@ export const HarnessSpecSchema = z maxTokens: z.number().int().min(1).optional(), timeoutSeconds: z.number().int().min(1).optional(), truncation: HarnessTruncationConfigSchema.optional(), - containerUri: z.string().optional(), - dockerfile: z.string().optional(), + containerUri: z.string().min(1).optional(), + dockerfile: z.string().min(1).optional(), executionRoleArn: z.string().optional(), networkMode: NetworkModeSchema.optional(), networkConfig: NetworkConfigSchema.optional(), @@ -236,6 +236,13 @@ export const HarnessSpecSchema = z tags: TagsSchema.optional(), }) .superRefine((data, ctx) => { + if (data.containerUri !== undefined && data.dockerfile !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'containerUri and dockerfile are mutually exclusive', + path: ['containerUri'], + }); + } if (data.networkMode === 'VPC' && !data.networkConfig) { ctx.addIssue({ code: z.ZodIssueCode.custom, From 7a5305c293d583e6bffe2289295f7504a29f7a0d Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:45:18 -0400 Subject: [PATCH 16/49] feat: display harnesses in status screen (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds harness resource type to status command and ResourceGraph: - diffResourceSet for harnesses (local vs deployed) - Live status enrichment via getHarness API call - Shows harness status (READY/CREATING/FAILED) and underlying runtime ARN from the managed environment - New ◎ icon for harnesses in resource graph + legend --- src/cli/aws/agentcore-harness.ts | 3 + src/cli/commands/status/action.ts | 92 ++++++++++++------------ src/cli/tui/components/ResourceGraph.tsx | 30 +++++++- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts index 1c886c892..4f1a6a29d 100644 --- a/src/cli/aws/agentcore-harness.ts +++ b/src/cli/aws/agentcore-harness.ts @@ -52,6 +52,9 @@ export interface HarnessEnvironmentArtifact { } export interface HarnessAgentCoreRuntimeEnvironment { + agentRuntimeArn?: string; + agentRuntimeId?: string; + agentRuntimeName?: string; lifecycleConfiguration?: Record; networkConfiguration?: Record; filesystemConfigurations?: Record[]; diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 74bd366fd..c0d96d29a 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -13,14 +13,14 @@ export type { ResourceDeploymentState }; export interface ResourceStatusEntry { resourceType: | 'agent' + | 'harness' | 'memory' | 'credential' | 'gateway' | 'evaluator' | 'online-eval' | 'policy-engine' - | 'policy' - | 'harness'; + | 'policy'; name: string; deploymentState: ResourceDeploymentState; identifier?: string; @@ -129,6 +129,13 @@ export function computeResourceStatuses( getIdentifier: deployed => deployed.runtimeArn, }); + const harnesses = diffResourceSet({ + resourceType: 'harness', + localItems: project.harnesses ?? [], + deployedRecord: resources?.harnesses ?? {}, + getIdentifier: deployed => deployed.harnessArn, + }); + const credentials = diffResourceSet({ resourceType: 'credential', localItems: project.credentials, @@ -204,15 +211,9 @@ export function computeResourceStatuses( getDeployedKey: item => `${item.engineName}/${item.name}`, }); - const harnesses = diffResourceSet({ - resourceType: 'harness', - localItems: (project.harnesses ?? []).map(h => ({ name: h.name })), - deployedRecord: resources?.harnesses ?? {}, - getIdentifier: deployed => deployed.harnessArn, - }); - return [ ...agents, + ...harnesses, ...credentials, ...memories, ...gateways, @@ -319,6 +320,42 @@ export async function handleProjectStatus( logger.endStep(hasErrors ? 'error' : 'success'); } + // Enrich deployed harnesses with live status + const harnessStates = targetResources?.harnesses ?? {}; + const deployedHarnesses = resources.filter( + e => e.resourceType === 'harness' && e.deploymentState === 'deployed' && harnessStates[e.name] + ); + + if (deployedHarnesses.length > 0) { + logger.startStep( + `Fetch harness status (${deployedHarnesses.length} harness${deployedHarnesses.length !== 1 ? 'es' : ''})` + ); + + await Promise.all( + resources.map(async (entry, i) => { + if (entry.resourceType !== 'harness' || entry.deploymentState !== 'deployed') return; + + const harnessState = harnessStates[entry.name]; + if (!harnessState) return; + + try { + const result = await getHarness({ region: targetConfig.region, harnessId: harnessState.harnessId }); + const runtimeArn = result.harness.environment?.agentCoreRuntimeEnvironment?.agentRuntimeArn; + const detail = runtimeArn ? `${result.harness.status} · Runtime: ${runtimeArn}` : result.harness.status; + resources[i] = { ...entry, detail }; + logger.log(` ${entry.name}: ${result.harness.status} (${harnessState.harnessId})`); + } catch (error) { + const errorMsg = getErrorMessage(error); + resources[i] = { ...entry, error: errorMsg }; + logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); + } + }) + ); + + const hasHarnessErrors = resources.some(r => r.resourceType === 'harness' && r.error); + logger.endStep(hasHarnessErrors ? 'error' : 'success'); + } + // Enrich deployed evaluators with live status const evaluatorStates = targetResources?.evaluators ?? {}; const deployedEvaluators = resources.filter( @@ -394,43 +431,6 @@ export async function handleProjectStatus( const hasOnlineEvalErrors = resources.some(r => r.resourceType === 'online-eval' && r.error); logger.endStep(hasOnlineEvalErrors ? 'error' : 'success'); } - - // Enrich deployed harnesses with live status - const harnessStates = targetResources?.harnesses ?? {}; - const deployedHarnesses = resources.filter( - e => e.resourceType === 'harness' && e.deploymentState === 'deployed' && harnessStates[e.name] - ); - - if (deployedHarnesses.length > 0) { - logger.startStep( - `Fetch harness status (${deployedHarnesses.length} harness${deployedHarnesses.length !== 1 ? 'es' : ''})` - ); - - await Promise.all( - resources.map(async (entry, i) => { - if (entry.resourceType !== 'harness' || entry.deploymentState !== 'deployed') return; - - const harnessState = harnessStates[entry.name]; - if (!harnessState) return; - - try { - const harnessResult = await getHarness({ - region: targetConfig.region, - harnessId: harnessState.harnessId, - }); - resources[i] = { ...entry, detail: harnessResult.harness.status }; - logger.log(` ${entry.name}: ${harnessResult.harness.status} (${harnessState.harnessId})`); - } catch (error) { - const errorMsg = getErrorMessage(error); - resources[i] = { ...entry, error: errorMsg }; - logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); - } - }) - ); - - const hasHarnessErrors = resources.some(r => r.resourceType === 'harness' && r.error); - logger.endStep(hasHarnessErrors ? 'error' : 'success'); - } } logger.finalize(true); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 1d8d276c0..e4366d0d4 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -11,6 +11,7 @@ import React, { useMemo } from 'react'; const ICONS = { agent: '●', + harness: '◎', memory: '■', credential: '◇', gateway: '◆', @@ -20,7 +21,6 @@ const ICONS = { 'online-eval': '↻', 'policy-engine': '▣', policy: '▢', - harness: '⊞', } as const; interface ResourceGraphProps { @@ -121,6 +121,7 @@ export function getTargetDisplayText(target: AgentCoreGatewayTarget): string { export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: ResourceGraphProps) { const allAgents = project.runtimes ?? []; const agents = agentName ? allAgents.filter(a => a.name === agentName) : allAgents; + const harnesses = project.harnesses ?? []; const memories = project.memories ?? []; const credentials = project.credentials ?? []; const evaluators = project.evaluators ?? []; @@ -153,6 +154,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const hasContent = agents.length > 0 || + harnesses.length > 0 || memories.length > 0 || credentials.length > 0 || evaluators.length > 0 || @@ -197,6 +199,31 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res )} + {/* Harnesses */} + {harnesses.length > 0 && ( + + Harnesses + {harnesses.map(harness => { + const rsEntry = statusMap.get(`harness:${harness.name}`); + const harnessStatus = rsEntry?.error ? 'error' : rsEntry?.detail; + const harnessStatusColor = rsEntry?.error ? 'red' : getStatusColor(harnessStatus); + return ( + + ); + })} + + )} + {/* Memories */} {memories.length > 0 && ( @@ -414,6 +441,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res {'─'.repeat(50)} {ICONS.agent} agent{' '} + {ICONS.harness} harness{' '} {ICONS.memory} memory{' '} {ICONS.credential} credential{' '} {ICONS.evaluator} evaluator{' '} From 765f9d5f12c7f4da873ed221dd475b41285e6a52 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:45:52 -0400 Subject: [PATCH 17/49] feat: add --harness support to traces and logs commands (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `agentcore traces list`, `traces get`, and `logs` commands to support harness observability. When a project has only harnesses (no runtimes), the harness is auto-selected without requiring --harness flag. - Add resolveHarness() to resolve harness → underlying runtimeId - Add resolveAgentOrHarness() for unified resolution with auto-detection - Add --harness option to traces list, traces get, and logs commands - Mutual exclusion: --harness and --runtime cannot be used together - Extract runtimeId from agentRuntimeArn in deployed-state, with API fallback --- .../commands/logs/__tests__/action.test.ts | 26 ++-- src/cli/commands/logs/action.ts | 13 +- src/cli/commands/logs/command.tsx | 1 + src/cli/commands/logs/types.ts | 1 + src/cli/commands/traces/action.ts | 6 +- src/cli/commands/traces/command.tsx | 4 +- src/cli/commands/traces/types.ts | 2 + src/cli/operations/resolve-agent.ts | 128 ++++++++++++++++++ 8 files changed, 158 insertions(+), 23 deletions(-) diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 4c8b40162..dbeddb534 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -81,8 +81,8 @@ describe('resolveAgentContext', () => { ...overrides, }); - it('auto-selects single agent', () => { - const result = resolveAgentContext(makeContext(), {}); + it('auto-selects single agent', async () => { + const result = await resolveAgentContext(makeContext(), {}); expect(result.success).toBe(true); if (result.success) { expect(result.agentContext.agentName).toBe('MyAgent'); @@ -92,7 +92,7 @@ describe('resolveAgentContext', () => { } }); - it('errors for multiple agents without --agent flag', () => { + it('errors for multiple agents without --agent flag', async () => { const context = makeContext({ project: { name: 'TestProject', @@ -125,7 +125,7 @@ describe('resolveAgentContext', () => { harnesses: [], }, }); - const result = resolveAgentContext(context, {}); + const result = await resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('Multiple runtimes found'); @@ -134,7 +134,7 @@ describe('resolveAgentContext', () => { } }); - it('selects correct agent with --agent flag from multiple agents', () => { + it('selects correct agent with --agent flag from multiple agents', async () => { const context = makeContext({ project: { name: 'TestProject', @@ -187,7 +187,7 @@ describe('resolveAgentContext', () => { }, }, }); - const result = resolveAgentContext(context, { runtime: 'AgentB' }); + const result = await resolveAgentContext(context, { runtime: 'AgentB' }); expect(result.success).toBe(true); if (result.success) { expect(result.agentContext.agentName).toBe('AgentB'); @@ -195,15 +195,15 @@ describe('resolveAgentContext', () => { } }); - it('errors for unknown agent name', () => { - const result = resolveAgentContext(makeContext(), { runtime: 'UnknownAgent' }); + it('errors for unknown agent name', async () => { + const result = await resolveAgentContext(makeContext(), { runtime: 'UnknownAgent' }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain("Runtime 'UnknownAgent' not found"); } }); - it('errors when no agents defined', () => { + it('errors when no agents defined', async () => { const context = makeContext({ project: { name: 'TestProject', @@ -219,14 +219,14 @@ describe('resolveAgentContext', () => { harnesses: [], }, }); - const result = resolveAgentContext(context, {}); + const result = await resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('No runtimes defined'); + expect(result.error).toContain('No runtimes or harnesses defined'); } }); - it('errors when agent is not deployed', () => { + it('errors when agent is not deployed', async () => { const context = makeContext({ deployedState: { targets: { @@ -238,7 +238,7 @@ describe('resolveAgentContext', () => { }, }, }); - const result = resolveAgentContext(context, {}); + const result = await resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('is not deployed'); diff --git a/src/cli/commands/logs/action.ts b/src/cli/commands/logs/action.ts index 72be2c864..26d48f6f0 100644 --- a/src/cli/commands/logs/action.ts +++ b/src/cli/commands/logs/action.ts @@ -2,7 +2,7 @@ import { parseTimeString } from '../../../lib/utils'; import { searchLogs, streamLogs } from '../../aws/cloudwatch'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; import type { DeployedProjectConfig } from '../../operations/resolve-agent'; -import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent'; +import { loadDeployedProjectConfig, resolveAgentOrHarness } from '../../operations/resolve-agent'; import { VALID_LEVELS, buildFilterPattern } from './filter-pattern'; import type { LogsOptions } from './types'; @@ -44,13 +44,14 @@ export function formatLogLine(event: { timestamp: number; message: string }, jso } /** - * Resolve agent context from config + options + * Resolve agent context from config + options. + * Supports --harness to resolve a harness's underlying runtime. */ -export function resolveAgentContext( +export async function resolveAgentContext( context: DeployedProjectConfig, options: LogsOptions -): { success: true; agentContext: AgentContext } | { success: false; error: string } { - const result = resolveAgent(context, options); +): Promise<{ success: true; agentContext: AgentContext } | { success: false; error: string }> { + const result = await resolveAgentOrHarness(context, options); if (!result.success) { return { success: false, error: result.error }; } @@ -83,7 +84,7 @@ export async function handleLogs(options: LogsOptions): Promise { } const context = await loadDeployedProjectConfig(); - const resolution = resolveAgentContext(context, options); + const resolution = await resolveAgentContext(context, options); if (!resolution.success) { return { success: false, error: resolution.error }; diff --git a/src/cli/commands/logs/command.tsx b/src/cli/commands/logs/command.tsx index 05649887d..12728d995 100644 --- a/src/cli/commands/logs/command.tsx +++ b/src/cli/commands/logs/command.tsx @@ -21,6 +21,7 @@ export const registerLogs = (program: Command) => { .passThroughOptions() .description(COMMAND_DESCRIPTIONS.logs) .option('--runtime ', 'Select specific runtime') + .option('--harness ', 'Select specific harness') .option('--since