Skip to content

Commit 7db4a90

Browse files
committed
fix(payments): gate auto-wiring to Strands runtimes
wirePaymentCapability used to drop a Strands-shaped payments.py and regex-rewrite main.py for every runtime, regardless of framework. On LangGraph, GoogleADK, OpenAIAgents, and AutoGen templates the regex either no-ops or corrupts the agent's Agent() constructor (none of them accept plugins=). Detect the framework by import signature on main.py and skip non-Strands runtimes cleanly. Surface a warning on the add success path listing the skipped runtime names so the user knows payments must be wired manually for those frameworks. - PaymentManagerPrimitive.add: collect skippedRuntimes, return in result. - wirePaymentCapability: read main.py first; bail before any fs writes if "from strands import" is absent. - CLI add: warn when skippedRuntimes is non-empty. - Tests: 6 new cases for non-Strands gating + missing main.py; existing fixtures updated to include the strands import where the intent was the Strands wiring path.
1 parent e234267 commit 7db4a90

2 files changed

Lines changed: 182 additions & 28 deletions

File tree

src/cli/primitives/PaymentManagerPrimitive.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ export class PaymentManagerPrimitive extends BasePrimitive<AddPaymentManagerOpti
4747
readonly label = 'Payment Manager';
4848
readonly primitiveSchema = PaymentManagerSchema;
4949

50-
async add(options: AddPaymentManagerOptions): Promise<AddResult<{ managerName: string }>> {
50+
async add(
51+
options: AddPaymentManagerOptions
52+
): Promise<AddResult<{ managerName: string; skippedRuntimes?: string[] }>> {
5153
try {
5254
const project = await this.readProjectSpec();
5355

@@ -84,15 +86,24 @@ export class PaymentManagerPrimitive extends BasePrimitive<AddPaymentManagerOpti
8486

8587
await this.writeProjectSpec(project);
8688

87-
// Wire payment capability into all agents
89+
// Wire payment capability into all agents.
90+
// Only Strands runtimes get the auto-wiring today; other frameworks
91+
// (LangChain/LangGraph, GoogleADK, OpenAIAgents, VercelAI) do not have
92+
// a payments capability shim yet and would have their main.py corrupted
93+
// by the Strands-shaped template. Non-Strands runtimes are skipped with
94+
// a warning so the user knows they need to wire payments manually.
8895
const configRoot = findConfigRoot();
96+
const skippedRuntimes: string[] = [];
8997
if (configRoot) {
9098
for (const runtime of project.runtimes) {
91-
this.wirePaymentCapability(configRoot, runtime.codeLocation);
99+
const wired = this.wirePaymentCapability(configRoot, runtime.codeLocation);
100+
if (!wired) {
101+
skippedRuntimes.push(runtime.name);
102+
}
92103
}
93104
}
94105

95-
return { success: true, managerName: options.name };
106+
return { success: true, managerName: options.name, skippedRuntimes };
96107
} catch (err) {
97108
return { success: false, error: toError(err) };
98109
}
@@ -359,7 +370,16 @@ export class PaymentManagerPrimitive extends BasePrimitive<AddPaymentManagerOpti
359370
console.log(JSON.stringify(serializeResult(result)));
360371
} else if (result.success) {
361372
console.log(`Added payment manager '${result.managerName}'`);
362-
console.log(`\nPayment capability code has been added to your agent(s).`);
373+
if (result.skippedRuntimes && result.skippedRuntimes.length > 0) {
374+
console.warn(
375+
`\nWarning: payment capability auto-wiring skipped for non-Strands runtime(s): ${result.skippedRuntimes.join(', ')}.`
376+
);
377+
console.warn(
378+
`Payments are only auto-wired into Strands agents today. You will need to wire payment plugins manually for these runtimes.`
379+
);
380+
} else {
381+
console.log(`\nPayment capability code has been added to your agent(s).`);
382+
}
363383
console.log(
364384
`Add a payment connector with \`agentcore add payment-connector --manager ${result.managerName}\``
365385
);
@@ -482,28 +502,38 @@ export class PaymentManagerPrimitive extends BasePrimitive<AddPaymentManagerOpti
482502
* handled by the Handlebars template for new agents. For existing agents,
483503
* the user must manually update their entrypoint to use the factory pattern.
484504
*/
485-
private wirePaymentCapability(configRoot: string, codeLocation: string): void {
505+
private wirePaymentCapability(configRoot: string, codeLocation: string): boolean {
486506
const projectRoot = dirname(configRoot);
487507
const agentDir = resolve(projectRoot, codeLocation);
488508
const capDir = join(agentDir, 'capabilities', 'payments');
489509

490-
if (existsSync(join(capDir, 'payments.py'))) return;
510+
if (existsSync(join(capDir, 'payments.py'))) return true;
511+
512+
const mainPath = join(agentDir, 'main.py');
513+
if (!existsSync(mainPath)) return false;
514+
515+
const main = readFileSync(mainPath, 'utf-8');
516+
517+
// Only Strands templates have a payments capability shim today. The
518+
// shim's plugin pattern (Agent(plugins=[...])) is Strands-specific and
519+
// would not work for LangChain/LangGraph, GoogleADK, OpenAIAgents, etc.
520+
// Detect by import signature — the unrendered Handlebars template still
521+
// contains "from strands import" so this works pre- and post-render.
522+
const isStrandsAgent = /^from strands(\.|\s)/m.test(main) || main.includes('from strands import');
523+
if (!isStrandsAgent) {
524+
return false;
525+
}
491526

492527
const templateDir = getTemplatePath('python', 'http', 'strands', 'capabilities', 'payments');
493-
if (!existsSync(templateDir)) return;
528+
if (!existsSync(templateDir)) return false;
494529

495530
mkdirSync(capDir, { recursive: true });
496531
copyFileSync(join(templateDir, 'payments.py'), join(capDir, 'payments.py'));
497532
const initPath = join(capDir, '__init__.py');
498533
if (!existsSync(initPath)) writeFileSync(initPath, '');
499534
const parentInit = join(agentDir, 'capabilities', '__init__.py');
500535
if (!existsSync(parentInit)) writeFileSync(parentInit, '');
501-
502-
const mainPath = join(agentDir, 'main.py');
503-
if (!existsSync(mainPath)) return;
504-
505-
const main = readFileSync(mainPath, 'utf-8');
506-
if (main.includes('create_payments_plugin')) return;
536+
if (main.includes('create_payments_plugin')) return true;
507537

508538
const importLine = 'from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT';
509539

@@ -568,5 +598,6 @@ export class PaymentManagerPrimitive extends BasePrimitive<AddPaymentManagerOpti
568598
}
569599

570600
writeFileSync(mainPath, patched);
601+
return true;
571602
}
572603
}

src/cli/primitives/__tests__/wirePaymentCapability.test.ts

Lines changed: 137 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => {
294294
// Test 3 – Minimal agent: no known pattern
295295
// =========================================================================
296296
describe('minimal agent with neither get_or_create_agent nor Agent() pattern', () => {
297-
const minimalMain = ['def h(e, c): pass'].join('\n');
297+
const minimalMain = ['from strands import Agent', '', 'def h(e, c): pass'].join('\n');
298298

299299
beforeEach(() => {
300300
mockExistsSync.mockImplementation((p: string) => {
@@ -418,10 +418,13 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => {
418418
mockExistsSync.mockImplementation((p: string) => {
419419
if (p === TEMPLATE_DIR) return true;
420420
if (p === PAYMENTS_PY_DEST) return false;
421-
if (p === MAIN_PY) return false; // no main.py → skip patching
421+
if (p === MAIN_PY) return true;
422422
return false;
423423
});
424-
mockReadFileSync.mockReturnValue('');
424+
// Strands main.py — passes the framework gate; pattern doesn't match
425+
// either get_or_create or Agent() so file write is skipped, but cap dir
426+
// setup still runs.
427+
mockReadFileSync.mockReturnValue('from strands import Agent\n\ndef h(e, c): pass\n');
425428
});
426429

427430
it('creates capabilities/payments/ directory with recursive flag', async () => {
@@ -458,9 +461,11 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => {
458461
mockExistsSync.mockImplementation((p: string) => {
459462
if (p === TEMPLATE_DIR) return true;
460463
if (p === PAYMENTS_PY_DEST) return false;
461-
if (p === MAIN_PY) return false;
464+
if (p === MAIN_PY) return true;
462465
return false; // init files absent
463466
});
467+
// Strands main.py to pass the framework gate
468+
mockReadFileSync.mockReturnValue('from strands import Agent\n\ndef h(e, c): pass\n');
464469
});
465470

466471
it('creates capabilities/payments/__init__.py when absent', async () => {
@@ -479,11 +484,12 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => {
479484
mockExistsSync.mockImplementation((p: string) => {
480485
if (p === TEMPLATE_DIR) return true;
481486
if (p === PAYMENTS_PY_DEST) return false;
482-
if (p === MAIN_PY) return false;
487+
if (p === MAIN_PY) return true;
483488
if (p === CAP_INIT) return true; // already exists
484489
if (p === PARENT_INIT) return true; // already exists
485490
return false;
486491
});
492+
mockReadFileSync.mockReturnValue('from strands import Agent\n\ndef h(e, c): pass\n');
487493

488494
await callAdd(primitive, makeProject(CODE_LOCATION));
489495

@@ -539,24 +545,141 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => {
539545
expect(between).toBe('\n');
540546
});
541547

542-
it('prepends import at top of file when there are no existing imports', async () => {
543-
const noImports = ['def handler(event, context):', ' return {}'].join('\n');
548+
// Note: the "no existing imports" case is no longer reachable since
549+
// wirePaymentCapability requires `from strands import` to detect the
550+
// framework before wiring. A main.py with zero imports cannot be a
551+
// Strands template and is correctly skipped by the framework gate
552+
// (covered by the framework-gate tests below).
553+
});
554+
555+
// =========================================================================
556+
// Test 8 – Framework gate: skip non-Strands runtimes
557+
// =========================================================================
558+
describe('framework gate (non-Strands runtimes)', () => {
559+
/**
560+
* Each fixture is a snippet from one of the templates we ship. The
561+
* shared expectation is the same for all: when main.py is NOT a Strands
562+
* agent, wirePaymentCapability must NOT touch the filesystem at all
563+
* (no cap dir, no payments.py copy, no main.py rewrite). The success
564+
* result still returns true and lists the runtime name in skippedRuntimes.
565+
*/
566+
const fixtures: { framework: string; main: string }[] = [
567+
{
568+
framework: 'LangChain_LangGraph',
569+
main: [
570+
'import os',
571+
'from langchain_core.messages import HumanMessage',
572+
'from langgraph.prebuilt import create_react_agent',
573+
'from bedrock_agentcore.runtime import BedrockAgentCoreApp',
574+
'',
575+
'app = BedrockAgentCoreApp()',
576+
'',
577+
'@app.entrypoint',
578+
'async def invoke(payload, context):',
579+
' pass',
580+
].join('\n'),
581+
},
582+
{
583+
framework: 'GoogleADK',
584+
main: [
585+
'import os',
586+
'from google.adk.agents import Agent',
587+
'from google.adk.runners import Runner',
588+
'from bedrock_agentcore.runtime import BedrockAgentCoreApp',
589+
'',
590+
'app = BedrockAgentCoreApp()',
591+
].join('\n'),
592+
},
593+
{
594+
framework: 'OpenAIAgents',
595+
main: [
596+
'import os',
597+
'from agents import Agent, Runner',
598+
'from bedrock_agentcore.runtime import BedrockAgentCoreApp',
599+
'',
600+
'app = BedrockAgentCoreApp()',
601+
].join('\n'),
602+
},
603+
{
604+
framework: 'AutoGen',
605+
main: [
606+
'import os',
607+
'from autogen_agentchat.agents import AssistantAgent',
608+
'from bedrock_agentcore.runtime import BedrockAgentCoreApp',
609+
'',
610+
'app = BedrockAgentCoreApp()',
611+
].join('\n'),
612+
},
613+
];
614+
615+
for (const fixture of fixtures) {
616+
it(`does not wire payments into ${fixture.framework} main.py`, async () => {
617+
mockExistsSync.mockImplementation((p: string) => {
618+
if (p === TEMPLATE_DIR) return true;
619+
if (p === PAYMENTS_PY_DEST) return false;
620+
if (p === MAIN_PY) return true;
621+
return false;
622+
});
623+
mockReadFileSync.mockReturnValue(fixture.main);
624+
625+
const result = await callAdd(primitive, makeProject(CODE_LOCATION));
626+
627+
// add() still succeeds — the manager goes into agentcore.json
628+
expect(result.success).toBe(true);
629+
630+
// No filesystem mutations to the agent's source tree
631+
expect(mockMkdirSync).not.toHaveBeenCalled();
632+
expect(mockCopyFileSync).not.toHaveBeenCalled();
633+
const mainPyWrite = mockWriteFileSync.mock.calls.find((c: unknown[]) => (c[0] as string) === MAIN_PY);
634+
expect(mainPyWrite).toBeUndefined();
635+
const capInitWrite = mockWriteFileSync.mock.calls.find(
636+
(c: unknown[]) => (c[0] as string) === CAP_INIT || (c[0] as string) === PARENT_INIT
637+
);
638+
expect(capInitWrite).toBeUndefined();
639+
640+
// Runtime name surfaced for the CLI to warn the user
641+
if (result.success) {
642+
expect(result.skippedRuntimes).toContain('my-agent');
643+
}
644+
});
645+
}
544646

647+
it('skips wiring when main.py is missing entirely (cannot detect framework)', async () => {
545648
mockExistsSync.mockImplementation((p: string) => {
546649
if (p === TEMPLATE_DIR) return true;
547650
if (p === PAYMENTS_PY_DEST) return false;
548-
if (p === MAIN_PY) return true;
651+
if (p === MAIN_PY) return false;
549652
return false;
550653
});
551-
mockReadFileSync.mockReturnValue(noImports);
552654

553-
await callAdd(primitive, makeProject(CODE_LOCATION));
655+
const result = await callAdd(primitive, makeProject(CODE_LOCATION));
554656

555-
const written: string = mockWriteFileSync.mock.calls.find(
556-
(c: unknown[]) => (c[0] as string) === MAIN_PY
557-
)![1] as string;
657+
expect(result.success).toBe(true);
658+
expect(mockMkdirSync).not.toHaveBeenCalled();
659+
expect(mockCopyFileSync).not.toHaveBeenCalled();
660+
if (result.success) {
661+
expect(result.skippedRuntimes).toContain('my-agent');
662+
}
663+
});
558664

559-
expect(written.startsWith('from capabilities.payments.payments import create_payments_plugin')).toBe(true);
665+
it('still wires when "from strands" appears in a Strands-typed main.py', async () => {
666+
const strandsMain = ['from strands import Agent', '', '@app.entrypoint', 'def h(p, c):', ' pass'].join('\n');
667+
mockExistsSync.mockImplementation((p: string) => {
668+
if (p === TEMPLATE_DIR) return true;
669+
if (p === PAYMENTS_PY_DEST) return false;
670+
if (p === MAIN_PY) return true;
671+
return false;
672+
});
673+
mockReadFileSync.mockReturnValue(strandsMain);
674+
675+
const result = await callAdd(primitive, makeProject(CODE_LOCATION));
676+
677+
expect(result.success).toBe(true);
678+
expect(mockMkdirSync).toHaveBeenCalledWith(CAP_DIR, { recursive: true });
679+
expect(mockCopyFileSync).toHaveBeenCalledWith(PAYMENTS_PY_SRC, PAYMENTS_PY_DEST);
680+
if (result.success) {
681+
expect(result.skippedRuntimes).toEqual([]);
682+
}
560683
});
561684
});
562685
});

0 commit comments

Comments
 (0)