@aws/nx-plugin 1.0.0-rc.69 → 1.0.0-rc.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/migrations.json CHANGED
@@ -12,6 +12,11 @@
12
12
  "description": "Update the docs translate script to write whole translated files rather than editing them in place",
13
13
  "implementation": "./src/migrations/latest/translate-script-whole-file-writes/migration"
14
14
  },
15
+ "latest-py-agent-a2a-lifespan-construction": {
16
+ "version": "1.0.0-rc.70",
17
+ "description": "Move py#agent A2A agent construction (Strands and LangChain) out of module import time and into a FastAPI lifespan handler, stored on app.state; also updates with_session_id/session_id_context to use Generator instead of the deprecated Iterator return type",
18
+ "implementation": "./src/migrations/latest/py-agent-a2a-lifespan-construction/migration"
19
+ },
15
20
  "v1.0.0-rc.50-0001-modernize-function-props-cast": {
16
21
  "version": "1.0.0-rc.50",
17
22
  "description": "Replace the legacy angle-bracket FunctionProps type assertion with the modern as syntax in generated API constructs",
@@ -108,7 +113,7 @@
108
113
  "implementation": "./src/migrations/v1.0.0-rc.65/0002-rolldown-code-splitting/migration"
109
114
  },
110
115
  "sync-vended-versions": {
111
- "version": "1.0.0-rc.69",
116
+ "version": "1.0.0-rc.70",
112
117
  "description": "Sync vended dependency versions and the tracked plugin version to those vended by this release",
113
118
  "implementation": "./src/utils/version-upgrade-migration/migration"
114
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws/nx-plugin",
3
- "version": "1.0.0-rc.69",
3
+ "version": "1.0.0-rc.70",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/awslabs/nx-plugin-for-aws.git",
@@ -0,0 +1,3 @@
1
+ {
2
+ "description": "Move py#agent A2A agent construction (Strands and LangChain) out of module import time and into a FastAPI lifespan handler, stored on app.state; also updates with_session_id/session_id_context to use Generator instead of the deprecated Iterator return type"
3
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { type MigrationReturnObject, type Tree } from '@nx/devkit';
6
+ export default function migration(tree: Tree): Promise<MigrationReturnObject>;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */ import { getProjects, joinPathFragments, visitNotIgnoredFiles } from "@nx/devkit";
5
+ import { PY_AGENT_GENERATOR_INFO } from "../../../py/agent/generator.js";
6
+ import { addPythonDestructuredImport, applyGritQL, captureGritQLVariable, matchGritQL } from "../../../utils/ast.js";
7
+ import { formatFilesInSubtree } from "../../../utils/format.js";
8
+ /**
9
+ * Move py#agent A2A agent construction (Strands and LangChain) out of module
10
+ * import time and into a FastAPI `lifespan` handler, stored on `app.state`.
11
+ *
12
+ * The `v1.0.0-rc.61/0001-py-agent-lifespan-construction` migration (HTTP/AG-UI)
13
+ * deliberately left A2A alone, reasoning that `with_session_id` only touches
14
+ * its agent factory lazily on first use, so the eager `_agent_ctx.__enter__()`
15
+ * was never actually eager in practice. That held for `with_session_id`'s own
16
+ * laziness, but missed a separate trigger: `strands.multiagent.a2a.executor
17
+ * .StrandsA2AExecutor.__init__`, in its deprecated single-`agent=` mode, does
18
+ * `getattr(agent, "_session_manager", None)` - which the session-routing
19
+ * proxy's `__getattr__` happily serves by building the real underlying Agent
20
+ * right then, under a bogus "default" session, since no request has bound a
21
+ * real session id yet. That call happens inside `A2AServer.__init__`, which
22
+ * the old template constructed at plain module import time - so the "lazy"
23
+ * proxy was forced eager anyway, just one layer further down than the
24
+ * previous migration checked.
25
+ *
26
+ * Strands: `agent_factory=` mode sidesteps this - `StrandsA2AExecutor` takes
27
+ * an entirely different branch when given a factory (no `_session_manager`
28
+ * probe at all), and the one placeholder call `A2AServer.__init__` makes
29
+ * before `lifespan` has run returns a bare `SimpleNamespace` with just
30
+ * `name`/`description` - no `__getattr__`, nothing to force construction.
31
+ *
32
+ * LangChain: the compiled graph from `get_agent()` has no such trap, but was
33
+ * still built eagerly at import time (`_graph = get_agent()`). Moved into
34
+ * `lifespan` for the same reason HTTP/AG-UI were: it can require env vars
35
+ * (model id, region) or do network setup that isn't guaranteed available at
36
+ * import - e.g. under test collection or OpenAPI spec generation tooling.
37
+ *
38
+ * Also folds in an unrelated but adjacent fix: Pylance (Pyright) deprecates
39
+ * annotating a `@contextmanager`-decorated function's return type as
40
+ * `Iterator[T]`, in favor of the single-argument `Generator[T]` form enabled
41
+ * by PEP 696 default type parameters (Python 3.13+ - generated projects
42
+ * target >=3.14). `with_session_id_strands.py` and `session_context.py` both
43
+ * use this pattern. Both files are shared, protocol-agnostic files copied
44
+ * once per workspace (not per py#agent component), so that part scans the
45
+ * whole tree by basename rather than going through component metadata.
46
+ */ const pyMatch = (snippet)=>`language python\n\`${snippet}\``;
47
+ const pyDelete = (snippet)=>`${pyMatch(snippet)} => .`;
48
+ const pyRewrite = (from, to)=>{
49
+ const replacement = to.includes('\n') ? `raw\`${to}\`` : `\`${to}\``;
50
+ return `${pyMatch(from)} => ${replacement}`;
51
+ };
52
+ const AGENT_ENTER = '_agent = _agent_ctx.__enter__()';
53
+ const OLD_AGENT_CONTEXT = `_agent_ctx = with_session_id(
54
+ get_agent,
55
+ name="$name",
56
+ description="A Strands Agent exposed via the Agent-to-Agent (A2A) protocol.",
57
+ )`;
58
+ const NEW_CONSTANTS_AND_LIFESPAN = `AGENT_NAME = "$name"
59
+ AGENT_DESCRIPTION = "A Strands Agent exposed via the Agent-to-Agent (A2A) protocol."
60
+
61
+ # A2AServer.__init__ calls agent_factory once, synchronously, before the lifespan
62
+ # below has run - this is what it reads name/description from at that point.
63
+ # The cast is a lie about runtime shape, not behavior: only .name/.description
64
+ # are ever read off this value.
65
+ _card_placeholder = cast(
66
+ Agent, SimpleNamespace(name=AGENT_NAME, description=AGENT_DESCRIPTION)
67
+ )
68
+
69
+
70
+ @asynccontextmanager
71
+ async def lifespan(app: FastAPI):
72
+ with with_session_id(
73
+ get_agent,
74
+ name=AGENT_NAME,
75
+ description=AGENT_DESCRIPTION,
76
+ ) as agent:
77
+ app.state.agent = agent
78
+ yield`;
79
+ const A2A_SERVER_STATEMENT = 'a2a_server = A2AServer($args)';
80
+ const OLD_MOUNT_STATEMENT = 'app.mount("/", a2a_server.to_fastapi_app())';
81
+ const OLD_FASTAPI_APP = 'app = FastAPI()';
82
+ const NEW_FASTAPI_APP = 'app = FastAPI(lifespan=lifespan)';
83
+ const SERVER_MATCH = 'language python\n`A2AServer($args)` where { $args <: contains `agent=_agent` }';
84
+ const allMatch = async (tree, filePath, patterns)=>{
85
+ for (const pattern of patterns){
86
+ if (!await matchGritQL(tree, filePath, pattern)) return false;
87
+ }
88
+ return true;
89
+ };
90
+ const migrateStrandsA2AAgent = async (tree, mainPath, nextSteps)=>{
91
+ if (!tree.exists(mainPath)) return;
92
+ const contents = tree.read(mainPath, 'utf-8') ?? '';
93
+ if (!contents.includes('_agent_ctx = with_session_id(')) return;
94
+ const ready = await allMatch(tree, mainPath, [
95
+ pyMatch('import logging'),
96
+ pyMatch('from $mod import session_id_context, with_session_id'),
97
+ pyMatch(OLD_AGENT_CONTEXT),
98
+ pyMatch(AGENT_ENTER),
99
+ pyMatch(OLD_FASTAPI_APP),
100
+ pyMatch(OLD_MOUNT_STATEMENT),
101
+ SERVER_MATCH
102
+ ]);
103
+ const diverged = ()=>{
104
+ nextSteps.push(`${mainPath}: diverged from the generated Strands A2A shape - left untouched. Manually rebuild the agent inside the FastAPI \`lifespan\` and have \`agent_factory\` read it off \`app.state\` (see the py#agent generator's strands/a2a template).`);
105
+ };
106
+ if (!ready) {
107
+ diverged();
108
+ return;
109
+ }
110
+ await addPythonDestructuredImport(tree, mainPath, [
111
+ 'asynccontextmanager'
112
+ ], 'contextlib');
113
+ await addPythonDestructuredImport(tree, mainPath, [
114
+ 'SimpleNamespace'
115
+ ], 'types');
116
+ await addPythonDestructuredImport(tree, mainPath, [
117
+ 'cast'
118
+ ], 'typing');
119
+ await addPythonDestructuredImport(tree, mainPath, [
120
+ 'Agent'
121
+ ], 'strands');
122
+ await applyGritQL(tree, mainPath, pyRewrite(OLD_AGENT_CONTEXT, NEW_CONSTANTS_AND_LIFESPAN));
123
+ await applyGritQL(tree, mainPath, pyDelete(AGENT_ENTER));
124
+ // Swap the kwarg while the call is still in its original spot (any extra
125
+ // kwargs a user added are left alone), then capture the whole arg list so
126
+ // it can be reinserted at the call's new home below.
127
+ await applyGritQL(tree, mainPath, pyRewrite('agent=_agent', 'agent_factory=lambda _context_id: getattr(app.state, "agent", _card_placeholder)'));
128
+ const capturedArgs = await captureGritQLVariable(tree, mainPath, `language python\n\`${A2A_SERVER_STATEMENT}\``, 'args');
129
+ if (capturedArgs === undefined) {
130
+ diverged();
131
+ return;
132
+ }
133
+ await applyGritQL(tree, mainPath, pyDelete(A2A_SERVER_STATEMENT));
134
+ await applyGritQL(tree, mainPath, pyRewrite(OLD_FASTAPI_APP, NEW_FASTAPI_APP));
135
+ await applyGritQL(tree, mainPath, pyRewrite(OLD_MOUNT_STATEMENT, `a2a_server = A2AServer(${capturedArgs})\n\n${OLD_MOUNT_STATEMENT}`));
136
+ };
137
+ // --- LangChain A2A -----------------------------------------------------------
138
+ const LANGCHAIN_GRAPH_OLD = '_graph = get_agent()';
139
+ const LANGCHAIN_LIFESPAN_NEW = `@asynccontextmanager
140
+ async def lifespan(app: FastAPI):
141
+ app.state.agent = get_agent()
142
+ yield`;
143
+ const LANGCHAIN_GRAPH_INVOKE_OLD = '_graph.ainvoke($args)';
144
+ const LANGCHAIN_GRAPH_INVOKE_NEW = 'app.state.agent.ainvoke($args)';
145
+ const LANGCHAIN_APP_OLD = 'app = FastAPI(title="$name")';
146
+ const LANGCHAIN_APP_NEW = 'app = FastAPI(title="$name", lifespan=lifespan)';
147
+ const migrateLangchainA2AAgent = async (tree, mainPath, nextSteps)=>{
148
+ if (!tree.exists(mainPath)) return;
149
+ const contents = tree.read(mainPath, 'utf-8') ?? '';
150
+ if (!contents.includes(LANGCHAIN_GRAPH_OLD)) return;
151
+ const ready = await allMatch(tree, mainPath, [
152
+ pyMatch(LANGCHAIN_GRAPH_OLD),
153
+ pyMatch('from .agent import get_agent'),
154
+ pyMatch(LANGCHAIN_GRAPH_INVOKE_OLD),
155
+ pyMatch(LANGCHAIN_APP_OLD)
156
+ ]);
157
+ if (!ready) {
158
+ nextSteps.push(`${mainPath}: diverged from the generated LangChain A2A shape - left untouched. Manually wrap \`get_agent()\` in a \`lifespan\` handler storing the graph on \`app.state.agent\` (see the py#agent generator's langchain/a2a template).`);
159
+ return;
160
+ }
161
+ await addPythonDestructuredImport(tree, mainPath, [
162
+ 'asynccontextmanager'
163
+ ], 'contextlib');
164
+ await applyGritQL(tree, mainPath, pyRewrite(LANGCHAIN_GRAPH_OLD, LANGCHAIN_LIFESPAN_NEW));
165
+ await applyGritQL(tree, mainPath, pyRewrite(LANGCHAIN_GRAPH_INVOKE_OLD, LANGCHAIN_GRAPH_INVOKE_NEW));
166
+ await applyGritQL(tree, mainPath, pyRewrite(LANGCHAIN_APP_OLD, LANGCHAIN_APP_NEW));
167
+ };
168
+ // --- Iterator -> Generator (with_session_id_strands.py / session_context.py) ---
169
+ const WITH_SESSION_ID_IMPORT_OLD = 'from collections.abc import Callable, Iterator';
170
+ const WITH_SESSION_ID_IMPORT_NEW = 'from collections.abc import Callable, Generator';
171
+ const migrateWithSessionId = async (tree, filePath, nextSteps)=>{
172
+ const contents = tree.read(filePath, 'utf-8') ?? '';
173
+ if (!contents.includes('Iterator[Any]')) return;
174
+ const ready = await allMatch(tree, filePath, [
175
+ pyMatch(WITH_SESSION_ID_IMPORT_OLD),
176
+ pyMatch('Iterator[Any]')
177
+ ]);
178
+ if (!ready) {
179
+ nextSteps.push(`${filePath}: diverged from the generated shape - left untouched. Manually change the \`Iterator\` import and \`Iterator[Any]\` return annotation on \`with_session_id\` to \`Generator\`/\`Generator[Any]\` (see the agent-connection generator's with_session_id_strands.py template).`);
180
+ return;
181
+ }
182
+ await applyGritQL(tree, filePath, pyRewrite(WITH_SESSION_ID_IMPORT_OLD, WITH_SESSION_ID_IMPORT_NEW));
183
+ await applyGritQL(tree, filePath, pyRewrite('Iterator[Any]', 'Generator[Any]'));
184
+ };
185
+ const SESSION_CONTEXT_IMPORT_OLD = 'from collections.abc import Iterator';
186
+ const SESSION_CONTEXT_IMPORT_NEW = 'from collections.abc import Generator';
187
+ const migrateSessionContext = async (tree, filePath, nextSteps)=>{
188
+ const contents = tree.read(filePath, 'utf-8') ?? '';
189
+ if (!contents.includes('Iterator[None]')) return;
190
+ const ready = await allMatch(tree, filePath, [
191
+ pyMatch(SESSION_CONTEXT_IMPORT_OLD),
192
+ pyMatch('Iterator[None]')
193
+ ]);
194
+ if (!ready) {
195
+ nextSteps.push(`${filePath}: diverged from the generated shape - left untouched. Manually change the \`Iterator\` import and \`Iterator[None]\` return annotation on \`session_id_context\` to \`Generator\`/\`Generator[None]\` (see the agent-connection generator's session_context.py template).`);
196
+ return;
197
+ }
198
+ await applyGritQL(tree, filePath, pyRewrite(SESSION_CONTEXT_IMPORT_OLD, SESSION_CONTEXT_IMPORT_NEW));
199
+ await applyGritQL(tree, filePath, pyRewrite('Iterator[None]', 'Generator[None]'));
200
+ };
201
+ const findAgentComponents = (components)=>(components ?? []).filter((component)=>component.generator === PY_AGENT_GENERATOR_INFO.id);
202
+ export default async function migration(tree) {
203
+ const nextSteps = [];
204
+ for (const project of getProjects(tree).values()){
205
+ const components = findAgentComponents(project.metadata?.components);
206
+ for (const component of components){
207
+ if (!component.path || component.protocol !== 'a2a') {
208
+ continue;
209
+ }
210
+ const componentDir = component.path.endsWith('/agent.py') ? component.path.slice(0, -'/agent.py'.length) : component.path;
211
+ const mainPath = joinPathFragments(project.root, componentDir, 'main.py');
212
+ if ((component.framework ?? 'strands') === 'langchain') {
213
+ await migrateLangchainA2AAgent(tree, mainPath, nextSteps);
214
+ } else {
215
+ await migrateStrandsA2AAgent(tree, mainPath, nextSteps);
216
+ }
217
+ }
218
+ }
219
+ const filePaths = [];
220
+ visitNotIgnoredFiles(tree, '', (filePath)=>filePaths.push(filePath));
221
+ for (const filePath of filePaths){
222
+ if (filePath.endsWith('/with_session_id_strands.py')) {
223
+ await migrateWithSessionId(tree, filePath, nextSteps);
224
+ } else if (filePath.endsWith('/session_context.py')) {
225
+ await migrateSessionContext(tree, filePath, nextSteps);
226
+ }
227
+ }
228
+ await formatFilesInSubtree(tree);
229
+ return {
230
+ nextSteps
231
+ };
232
+ }
233
+
234
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../packages/nx-plugin/src/migrations/latest/py-agent-a2a-lifespan-construction/migration.ts"],"sourcesContent":["/**\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * SPDX-License-Identifier: Apache-2.0\n */\nimport {\n getProjects,\n joinPathFragments,\n type MigrationReturnObject,\n type Tree,\n visitNotIgnoredFiles,\n} from '@nx/devkit';\nimport { PY_AGENT_GENERATOR_INFO } from '../../../py/agent/generator';\nimport {\n addPythonDestructuredImport,\n applyGritQL,\n captureGritQLVariable,\n matchGritQL,\n} from '../../../utils/ast';\nimport { formatFilesInSubtree } from '../../../utils/format';\nimport type { ComponentMetadata } from '../../../utils/nx';\n\n/**\n * Move py#agent A2A agent construction (Strands and LangChain) out of module\n * import time and into a FastAPI `lifespan` handler, stored on `app.state`.\n *\n * The `v1.0.0-rc.61/0001-py-agent-lifespan-construction` migration (HTTP/AG-UI)\n * deliberately left A2A alone, reasoning that `with_session_id` only touches\n * its agent factory lazily on first use, so the eager `_agent_ctx.__enter__()`\n * was never actually eager in practice. That held for `with_session_id`'s own\n * laziness, but missed a separate trigger: `strands.multiagent.a2a.executor\n * .StrandsA2AExecutor.__init__`, in its deprecated single-`agent=` mode, does\n * `getattr(agent, \"_session_manager\", None)` - which the session-routing\n * proxy's `__getattr__` happily serves by building the real underlying Agent\n * right then, under a bogus \"default\" session, since no request has bound a\n * real session id yet. That call happens inside `A2AServer.__init__`, which\n * the old template constructed at plain module import time - so the \"lazy\"\n * proxy was forced eager anyway, just one layer further down than the\n * previous migration checked.\n *\n * Strands: `agent_factory=` mode sidesteps this - `StrandsA2AExecutor` takes\n * an entirely different branch when given a factory (no `_session_manager`\n * probe at all), and the one placeholder call `A2AServer.__init__` makes\n * before `lifespan` has run returns a bare `SimpleNamespace` with just\n * `name`/`description` - no `__getattr__`, nothing to force construction.\n *\n * LangChain: the compiled graph from `get_agent()` has no such trap, but was\n * still built eagerly at import time (`_graph = get_agent()`). Moved into\n * `lifespan` for the same reason HTTP/AG-UI were: it can require env vars\n * (model id, region) or do network setup that isn't guaranteed available at\n * import - e.g. under test collection or OpenAPI spec generation tooling.\n *\n * Also folds in an unrelated but adjacent fix: Pylance (Pyright) deprecates\n * annotating a `@contextmanager`-decorated function's return type as\n * `Iterator[T]`, in favor of the single-argument `Generator[T]` form enabled\n * by PEP 696 default type parameters (Python 3.13+ - generated projects\n * target >=3.14). `with_session_id_strands.py` and `session_context.py` both\n * use this pattern. Both files are shared, protocol-agnostic files copied\n * once per workspace (not per py#agent component), so that part scans the\n * whole tree by basename rather than going through component metadata.\n */\nconst pyMatch = (snippet: string) => `language python\\n\\`${snippet}\\``;\n\nconst pyDelete = (snippet: string) => `${pyMatch(snippet)} => .`;\n\nconst pyRewrite = (from: string, to: string): string => {\n const replacement = to.includes('\\n') ? `raw\\`${to}\\`` : `\\`${to}\\``;\n return `${pyMatch(from)} => ${replacement}`;\n};\n\nconst AGENT_ENTER = '_agent = _agent_ctx.__enter__()';\n\nconst OLD_AGENT_CONTEXT = `_agent_ctx = with_session_id(\n get_agent,\n name=\"$name\",\n description=\"A Strands Agent exposed via the Agent-to-Agent (A2A) protocol.\",\n)`;\n\nconst NEW_CONSTANTS_AND_LIFESPAN = `AGENT_NAME = \"$name\"\nAGENT_DESCRIPTION = \"A Strands Agent exposed via the Agent-to-Agent (A2A) protocol.\"\n\n# A2AServer.__init__ calls agent_factory once, synchronously, before the lifespan\n# below has run - this is what it reads name/description from at that point.\n# The cast is a lie about runtime shape, not behavior: only .name/.description\n# are ever read off this value.\n_card_placeholder = cast(\n Agent, SimpleNamespace(name=AGENT_NAME, description=AGENT_DESCRIPTION)\n)\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n with with_session_id(\n get_agent,\n name=AGENT_NAME,\n description=AGENT_DESCRIPTION,\n ) as agent:\n app.state.agent = agent\n yield`;\n\nconst A2A_SERVER_STATEMENT = 'a2a_server = A2AServer($args)';\n\nconst OLD_MOUNT_STATEMENT = 'app.mount(\"/\", a2a_server.to_fastapi_app())';\n\nconst OLD_FASTAPI_APP = 'app = FastAPI()';\n\nconst NEW_FASTAPI_APP = 'app = FastAPI(lifespan=lifespan)';\n\nconst SERVER_MATCH =\n 'language python\\n`A2AServer($args)` where { $args <: contains `agent=_agent` }';\n\nconst allMatch = async (\n tree: Tree,\n filePath: string,\n patterns: string[],\n): Promise<boolean> => {\n for (const pattern of patterns) {\n if (!(await matchGritQL(tree, filePath, pattern))) return false;\n }\n return true;\n};\n\nconst migrateStrandsA2AAgent = async (\n tree: Tree,\n mainPath: string,\n nextSteps: string[],\n): Promise<void> => {\n if (!tree.exists(mainPath)) return;\n\n const contents = tree.read(mainPath, 'utf-8') ?? '';\n if (!contents.includes('_agent_ctx = with_session_id(')) return;\n\n const ready = await allMatch(tree, mainPath, [\n pyMatch('import logging'),\n pyMatch('from $mod import session_id_context, with_session_id'),\n pyMatch(OLD_AGENT_CONTEXT),\n pyMatch(AGENT_ENTER),\n pyMatch(OLD_FASTAPI_APP),\n pyMatch(OLD_MOUNT_STATEMENT),\n SERVER_MATCH,\n ]);\n\n const diverged = () => {\n nextSteps.push(\n `${mainPath}: diverged from the generated Strands A2A shape - left untouched. Manually rebuild the agent inside the FastAPI \\`lifespan\\` and have \\`agent_factory\\` read it off \\`app.state\\` (see the py#agent generator's strands/a2a template).`,\n );\n };\n\n if (!ready) {\n diverged();\n return;\n }\n\n await addPythonDestructuredImport(\n tree,\n mainPath,\n ['asynccontextmanager'],\n 'contextlib',\n );\n await addPythonDestructuredImport(\n tree,\n mainPath,\n ['SimpleNamespace'],\n 'types',\n );\n await addPythonDestructuredImport(tree, mainPath, ['cast'], 'typing');\n await addPythonDestructuredImport(tree, mainPath, ['Agent'], 'strands');\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(OLD_AGENT_CONTEXT, NEW_CONSTANTS_AND_LIFESPAN),\n );\n await applyGritQL(tree, mainPath, pyDelete(AGENT_ENTER));\n\n // Swap the kwarg while the call is still in its original spot (any extra\n // kwargs a user added are left alone), then capture the whole arg list so\n // it can be reinserted at the call's new home below.\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(\n 'agent=_agent',\n 'agent_factory=lambda _context_id: getattr(app.state, \"agent\", _card_placeholder)',\n ),\n );\n const capturedArgs = await captureGritQLVariable(\n tree,\n mainPath,\n `language python\\n\\`${A2A_SERVER_STATEMENT}\\``,\n 'args',\n );\n if (capturedArgs === undefined) {\n diverged();\n return;\n }\n\n await applyGritQL(tree, mainPath, pyDelete(A2A_SERVER_STATEMENT));\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(OLD_FASTAPI_APP, NEW_FASTAPI_APP),\n );\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(\n OLD_MOUNT_STATEMENT,\n `a2a_server = A2AServer(${capturedArgs})\\n\\n${OLD_MOUNT_STATEMENT}`,\n ),\n );\n};\n\n// --- LangChain A2A -----------------------------------------------------------\n\nconst LANGCHAIN_GRAPH_OLD = '_graph = get_agent()';\n\nconst LANGCHAIN_LIFESPAN_NEW = `@asynccontextmanager\nasync def lifespan(app: FastAPI):\n app.state.agent = get_agent()\n yield`;\n\nconst LANGCHAIN_GRAPH_INVOKE_OLD = '_graph.ainvoke($args)';\n\nconst LANGCHAIN_GRAPH_INVOKE_NEW = 'app.state.agent.ainvoke($args)';\n\nconst LANGCHAIN_APP_OLD = 'app = FastAPI(title=\"$name\")';\n\nconst LANGCHAIN_APP_NEW = 'app = FastAPI(title=\"$name\", lifespan=lifespan)';\n\nconst migrateLangchainA2AAgent = async (\n tree: Tree,\n mainPath: string,\n nextSteps: string[],\n): Promise<void> => {\n if (!tree.exists(mainPath)) return;\n\n const contents = tree.read(mainPath, 'utf-8') ?? '';\n if (!contents.includes(LANGCHAIN_GRAPH_OLD)) return;\n\n const ready = await allMatch(tree, mainPath, [\n pyMatch(LANGCHAIN_GRAPH_OLD),\n pyMatch('from .agent import get_agent'),\n pyMatch(LANGCHAIN_GRAPH_INVOKE_OLD),\n pyMatch(LANGCHAIN_APP_OLD),\n ]);\n\n if (!ready) {\n nextSteps.push(\n `${mainPath}: diverged from the generated LangChain A2A shape - left untouched. Manually wrap \\`get_agent()\\` in a \\`lifespan\\` handler storing the graph on \\`app.state.agent\\` (see the py#agent generator's langchain/a2a template).`,\n );\n return;\n }\n\n await addPythonDestructuredImport(\n tree,\n mainPath,\n ['asynccontextmanager'],\n 'contextlib',\n );\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(LANGCHAIN_GRAPH_OLD, LANGCHAIN_LIFESPAN_NEW),\n );\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(LANGCHAIN_GRAPH_INVOKE_OLD, LANGCHAIN_GRAPH_INVOKE_NEW),\n );\n await applyGritQL(\n tree,\n mainPath,\n pyRewrite(LANGCHAIN_APP_OLD, LANGCHAIN_APP_NEW),\n );\n};\n\n// --- Iterator -> Generator (with_session_id_strands.py / session_context.py) ---\n\nconst WITH_SESSION_ID_IMPORT_OLD =\n 'from collections.abc import Callable, Iterator';\nconst WITH_SESSION_ID_IMPORT_NEW =\n 'from collections.abc import Callable, Generator';\n\nconst migrateWithSessionId = async (\n tree: Tree,\n filePath: string,\n nextSteps: string[],\n): Promise<void> => {\n const contents = tree.read(filePath, 'utf-8') ?? '';\n if (!contents.includes('Iterator[Any]')) return;\n\n const ready = await allMatch(tree, filePath, [\n pyMatch(WITH_SESSION_ID_IMPORT_OLD),\n pyMatch('Iterator[Any]'),\n ]);\n\n if (!ready) {\n nextSteps.push(\n `${filePath}: diverged from the generated shape - left untouched. Manually change the \\`Iterator\\` import and \\`Iterator[Any]\\` return annotation on \\`with_session_id\\` to \\`Generator\\`/\\`Generator[Any]\\` (see the agent-connection generator's with_session_id_strands.py template).`,\n );\n return;\n }\n\n await applyGritQL(\n tree,\n filePath,\n pyRewrite(WITH_SESSION_ID_IMPORT_OLD, WITH_SESSION_ID_IMPORT_NEW),\n );\n await applyGritQL(\n tree,\n filePath,\n pyRewrite('Iterator[Any]', 'Generator[Any]'),\n );\n};\n\nconst SESSION_CONTEXT_IMPORT_OLD = 'from collections.abc import Iterator';\nconst SESSION_CONTEXT_IMPORT_NEW = 'from collections.abc import Generator';\n\nconst migrateSessionContext = async (\n tree: Tree,\n filePath: string,\n nextSteps: string[],\n): Promise<void> => {\n const contents = tree.read(filePath, 'utf-8') ?? '';\n if (!contents.includes('Iterator[None]')) return;\n\n const ready = await allMatch(tree, filePath, [\n pyMatch(SESSION_CONTEXT_IMPORT_OLD),\n pyMatch('Iterator[None]'),\n ]);\n\n if (!ready) {\n nextSteps.push(\n `${filePath}: diverged from the generated shape - left untouched. Manually change the \\`Iterator\\` import and \\`Iterator[None]\\` return annotation on \\`session_id_context\\` to \\`Generator\\`/\\`Generator[None]\\` (see the agent-connection generator's session_context.py template).`,\n );\n return;\n }\n\n await applyGritQL(\n tree,\n filePath,\n pyRewrite(SESSION_CONTEXT_IMPORT_OLD, SESSION_CONTEXT_IMPORT_NEW),\n );\n await applyGritQL(\n tree,\n filePath,\n pyRewrite('Iterator[None]', 'Generator[None]'),\n );\n};\n\nconst findAgentComponents = (\n components: ComponentMetadata[] | undefined,\n): ComponentMetadata[] =>\n (components ?? []).filter(\n (component) => component.generator === PY_AGENT_GENERATOR_INFO.id,\n );\n\nexport default async function migration(\n tree: Tree,\n): Promise<MigrationReturnObject> {\n const nextSteps: string[] = [];\n\n for (const project of getProjects(tree).values()) {\n const components = findAgentComponents(\n (project.metadata as { components?: ComponentMetadata[] })?.components,\n );\n\n for (const component of components) {\n if (!component.path || component.protocol !== 'a2a') {\n continue;\n }\n\n const componentDir = component.path.endsWith('/agent.py')\n ? component.path.slice(0, -'/agent.py'.length)\n : component.path;\n const mainPath = joinPathFragments(project.root, componentDir, 'main.py');\n\n if ((component.framework ?? 'strands') === 'langchain') {\n await migrateLangchainA2AAgent(tree, mainPath, nextSteps);\n } else {\n await migrateStrandsA2AAgent(tree, mainPath, nextSteps);\n }\n }\n }\n\n const filePaths: string[] = [];\n visitNotIgnoredFiles(tree, '', (filePath) => filePaths.push(filePath));\n\n for (const filePath of filePaths) {\n if (filePath.endsWith('/with_session_id_strands.py')) {\n await migrateWithSessionId(tree, filePath, nextSteps);\n } else if (filePath.endsWith('/session_context.py')) {\n await migrateSessionContext(tree, filePath, nextSteps);\n }\n }\n\n await formatFilesInSubtree(tree);\n\n return { nextSteps };\n}\n"],"names":["getProjects","joinPathFragments","visitNotIgnoredFiles","PY_AGENT_GENERATOR_INFO","addPythonDestructuredImport","applyGritQL","captureGritQLVariable","matchGritQL","formatFilesInSubtree","pyMatch","snippet","pyDelete","pyRewrite","from","to","replacement","includes","AGENT_ENTER","OLD_AGENT_CONTEXT","NEW_CONSTANTS_AND_LIFESPAN","A2A_SERVER_STATEMENT","OLD_MOUNT_STATEMENT","OLD_FASTAPI_APP","NEW_FASTAPI_APP","SERVER_MATCH","allMatch","tree","filePath","patterns","pattern","migrateStrandsA2AAgent","mainPath","nextSteps","exists","contents","read","ready","diverged","push","capturedArgs","undefined","LANGCHAIN_GRAPH_OLD","LANGCHAIN_LIFESPAN_NEW","LANGCHAIN_GRAPH_INVOKE_OLD","LANGCHAIN_GRAPH_INVOKE_NEW","LANGCHAIN_APP_OLD","LANGCHAIN_APP_NEW","migrateLangchainA2AAgent","WITH_SESSION_ID_IMPORT_OLD","WITH_SESSION_ID_IMPORT_NEW","migrateWithSessionId","SESSION_CONTEXT_IMPORT_OLD","SESSION_CONTEXT_IMPORT_NEW","migrateSessionContext","findAgentComponents","components","filter","component","generator","id","migration","project","values","metadata","path","protocol","componentDir","endsWith","slice","length","root","framework","filePaths"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  import uuid
4
+ from contextlib import asynccontextmanager
4
5
 
5
6
  from a2a.server.agent_execution import AgentExecutor, RequestContext
6
7
  from a2a.server.apps import A2AStarletteApplication
@@ -23,7 +24,11 @@ RUNTIME_URL = os.environ.get("AGENTCORE_RUNTIME_URL", f"http://localhost:{PORT}/
23
24
  SESSION_ID_HEADER = "x-amzn-bedrock-agentcore-runtime-session-id"
24
25
  DEFAULT_MODES = ["text/plain"]
25
26
 
26
- _graph = get_agent()
27
+
28
+ @asynccontextmanager
29
+ async def lifespan(app: FastAPI):
30
+ app.state.agent = get_agent()
31
+ yield
27
32
 
28
33
 
29
34
  class _GraphAgentExecutor(AgentExecutor):
@@ -37,7 +42,7 @@ class _GraphAgentExecutor(AgentExecutor):
37
42
  # The session bound by the middleware drives the LangGraph thread, keeping
38
43
  # checkpointed conversation state separate per caller session.
39
44
  session_id = get_current_session_id() or task.context_id
40
- result = await _graph.ainvoke(
45
+ result = await app.state.agent.ainvoke(
41
46
  {"messages": [{"role": "user", "content": context.get_user_input()}]},
42
47
  {"configurable": {"thread_id": session_id}},
43
48
  )
@@ -77,7 +82,7 @@ class _SessionIdMiddleware(BaseHTTPMiddleware):
77
82
  return await call_next(request)
78
83
 
79
84
 
80
- app = FastAPI(title="<%= agentNameClassName %>")
85
+ app = FastAPI(title="<%= agentNameClassName %>", lifespan=lifespan)
81
86
  app.add_middleware(_SessionIdMiddleware)
82
87
 
83
88
 
@@ -1,9 +1,13 @@
1
1
  import logging
2
2
  import os
3
3
  import uuid
4
+ from contextlib import asynccontextmanager
5
+ from types import SimpleNamespace
6
+ from typing import cast
4
7
 
5
8
  from fastapi import FastAPI, Request
6
9
  from starlette.middleware.base import BaseHTTPMiddleware
10
+ from strands import Agent
7
11
  from strands.multiagent.a2a import A2AServer
8
12
  from <%- agentConnectionModuleName %> import session_id_context, with_session_id
9
13
 
@@ -14,23 +18,27 @@ logging.basicConfig(level=logging.INFO)
14
18
  PORT = int(os.environ.get("PORT", "9000"))
15
19
  RUNTIME_URL = os.environ.get("AGENTCORE_RUNTIME_URL", f"http://localhost:{PORT}/")
16
20
  SESSION_ID_HEADER = "x-amzn-bedrock-agentcore-runtime-session-id"
21
+ AGENT_NAME = "<%= agentNameClassName %>"
22
+ AGENT_DESCRIPTION = "A Strands Agent exposed via the Agent-to-Agent (A2A) protocol."
17
23
 
18
- _agent_ctx = with_session_id(
19
- get_agent,
20
- name="<%= agentNameClassName %>",
21
- description="A Strands Agent exposed via the Agent-to-Agent (A2A) protocol.",
24
+ # A2AServer.__init__ calls agent_factory once, synchronously, before the lifespan
25
+ # below has run - this is what it reads name/description from at that point.
26
+ # The cast is a lie about runtime shape, not behavior: only .name/.description
27
+ # are ever read off this value.
28
+ _card_placeholder = cast(
29
+ Agent, SimpleNamespace(name=AGENT_NAME, description=AGENT_DESCRIPTION)
22
30
  )
23
- _agent = _agent_ctx.__enter__()
24
31
 
25
- a2a_server = A2AServer(
26
- agent=_agent,
27
- port=PORT,
28
- http_url=RUNTIME_URL,
29
- serve_at_root=True,
30
- # Skip tool-registry introspection for the agent card — the per-session
31
- # agents aren't constructed yet at import time.
32
- skills=[],
33
- )
32
+
33
+ @asynccontextmanager
34
+ async def lifespan(app: FastAPI):
35
+ with with_session_id(
36
+ get_agent,
37
+ name=AGENT_NAME,
38
+ description=AGENT_DESCRIPTION,
39
+ ) as agent:
40
+ app.state.agent = agent
41
+ yield
34
42
 
35
43
 
36
44
  class _SessionIdMiddleware(BaseHTTPMiddleware):
@@ -42,7 +50,7 @@ class _SessionIdMiddleware(BaseHTTPMiddleware):
42
50
  return await call_next(request)
43
51
 
44
52
 
45
- app = FastAPI()
53
+ app = FastAPI(lifespan=lifespan)
46
54
  app.add_middleware(_SessionIdMiddleware)
47
55
 
48
56
 
@@ -51,4 +59,14 @@ def ping() -> dict[str, str]:
51
59
  return {"status": "Healthy"}
52
60
 
53
61
 
62
+ a2a_server = A2AServer(
63
+ agent_factory=lambda _context_id: getattr(app.state, "agent", _card_placeholder),
64
+ port=PORT,
65
+ http_url=RUNTIME_URL,
66
+ serve_at_root=True,
67
+ # Skip tool-registry introspection for the agent card — the per-session
68
+ # agents aren't constructed yet at startup.
69
+ skills=[],
70
+ )
71
+
54
72
  app.mount("/", a2a_server.to_fastapi_app())
@@ -1,4 +1,4 @@
1
- from collections.abc import Iterator
1
+ from collections.abc import Generator
2
2
  from contextlib import contextmanager
3
3
  from contextvars import ContextVar
4
4
 
@@ -13,7 +13,7 @@ def get_current_session_id() -> str | None:
13
13
 
14
14
 
15
15
  @contextmanager
16
- def session_id_context(session_id: str) -> Iterator[None]:
16
+ def session_id_context(session_id: str) -> Generator[None]:
17
17
  """Bind *session_id* as the current session for the scope of the block."""
18
18
  token = _session_id_var.set(session_id)
19
19
  try:
@@ -1,4 +1,4 @@
1
- from collections.abc import Callable, Iterator
1
+ from collections.abc import Callable, Generator
2
2
  from contextlib import AbstractContextManager, ExitStack, contextmanager
3
3
  from typing import Any
4
4
 
@@ -11,7 +11,7 @@ def with_session_id(
11
11
  *,
12
12
  name: str,
13
13
  description: str,
14
- ) -> Iterator[Any]:
14
+ ) -> Generator[Any]:
15
15
  """Wrap an agent factory so each session gets its own cached Agent."""
16
16
  stack = ExitStack()
17
17
  agents: dict[str, Any] = {}