From 1951ff6bdc0f5fc2e869253765c6384a7d87a86e Mon Sep 17 00:00:00 2001 From: dpatlut Date: Wed, 23 Sep 2026 00:44:17 -0400 Subject: [PATCH] Add LangGraph migration examples for Python and TypeScript --- .../migration/langgraph/langgraph_before.py | 223 ++++++++++++++++ python/migration/langgraph/requirements.txt | 4 + python/migration/langgraph/strands_after.py | 148 +++++++++++ .../langgraph/langgraph-before/package.json | 26 ++ .../langgraph/langgraph-before/src/index.ts | 239 ++++++++++++++++++ .../langgraph/langgraph-before/tsconfig.json | 27 ++ .../langgraph/strands-after/package.json | 23 ++ .../langgraph/strands-after/src/index.ts | 166 ++++++++++++ .../langgraph/strands-after/tsconfig.json | 17 ++ 9 files changed, 873 insertions(+) create mode 100644 python/migration/langgraph/langgraph_before.py create mode 100644 python/migration/langgraph/requirements.txt create mode 100644 python/migration/langgraph/strands_after.py create mode 100644 typescript/migration/langgraph/langgraph-before/package.json create mode 100644 typescript/migration/langgraph/langgraph-before/src/index.ts create mode 100644 typescript/migration/langgraph/langgraph-before/tsconfig.json create mode 100644 typescript/migration/langgraph/strands-after/package.json create mode 100644 typescript/migration/langgraph/strands-after/src/index.ts create mode 100644 typescript/migration/langgraph/strands-after/tsconfig.json diff --git a/python/migration/langgraph/langgraph_before.py b/python/migration/langgraph/langgraph_before.py new file mode 100644 index 00000000..b9ed7a37 --- /dev/null +++ b/python/migration/langgraph/langgraph_before.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +# Case Triage in LangGraph (migration starting point) + +The "before" side of the [Migrate from LangGraph](https://strandsagents.com/docs/user-guide/migrate/langgraph/) +guide: a three-stage case-triage graph built with LangGraph, kept deliberately +close to the shape the guide describes so it can be compared line by line with +`strands_after.py`. + +The graph has five nodes for three stages, because in a `StateGraph` a tool call +is a node the graph routes to and back from. + +Each stage gets its own message window (`research_messages` and so on) through +the `messages_key` argument of `ToolNode` and `tools_condition`. The guide's +inline snippet uses one `messages` key for brevity. + + pip install langgraph langgraph-checkpoint-sqlite langchain-aws + python langgraph_before.py [case-5501] + +Needs Python 3.10 or newer and AWS credentials with Bedrock access. Set +`CASE_MODEL_ID` to pin a model. +Checkpoints go to `./cases.db` in the working directory; delete it to start a +case over. +""" + +import os +import sqlite3 +import sys +from typing import Annotated, TypedDict + +from langchain_aws import ChatBedrockConverse +from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_core.tools import tool +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition + +MODEL_ID = os.environ.get("CASE_MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0") +REGION = os.environ.get("AWS_REGION", "us-east-1") + +# Stand-in for the record system a real deployment would query. +CASE_RECORDS = { + "case-4127": ( + "Case case-4127 (tenant dispute, filed 2026-03-02)\n" + "Summary: Tenant withheld two months rent citing an unrepaired heating " + "system. Landlord filed for possession.\n" + "Facts on file:\n" + "- Heating fault first reported by tenant on 2025-11-14.\n" + "- Landlord sent a contractor on 2025-12-20, 36 days later.\n" + "- Tenant withheld rent for January and February 2026.\n" + "- Local code requires habitable heat within 14 days of written notice." + ), + "case-5501": ( + "Case case-5501 (deposit withholding, filed 2026-05-19)\n" + "Summary: Landlord retained the full deposit for cleaning after a " + "14-month tenancy.\n" + "Facts on file:\n" + "- Move-out inspection recorded normal wear on carpets.\n" + "- No itemized deduction statement was sent within the statutory 21 days.\n" + "- Tenant provided dated move-out photographs." + ), +} + +PRECEDENTS = [ + ("repair delay rent withholding", + "P-118: Rent withholding upheld where the landlord exceeded the statutory " + "repair window after written notice."), + ("deposit itemization deadline", + "P-204: Failure to send an itemized deduction statement within the statutory " + "period forfeits the right to withhold."), + ("normal wear and tear", + "P-077: Ordinary carpet wear over a tenancy of more than twelve months is not " + "chargeable to the tenant."), +] + + +@tool +def fetch_case_record(case_id: str) -> str: + """Fetch the filed record for a case. + + Args: + case_id: Identifier of the case to fetch + """ + return CASE_RECORDS.get(case_id, f"No filed record exists for {case_id}.") + + +@tool +def search_precedent(question: str) -> str: + """Search prior decisions for relevant precedent. + + Args: + question: What to look for in prior decisions + """ + words = {w.strip(".,;:?").lower() for w in question.split()} + hits = [text for topic, text in PRECEDENTS if words & set(topic.split())] + return "\n".join(hits or [text for _, text in PRECEDENTS]) + + +class CaseState(TypedDict): + """One shared state object, as LangGraph expects.""" + + research_messages: Annotated[list[AnyMessage], add_messages] + analysis_messages: Annotated[list[AnyMessage], add_messages] + review_messages: Annotated[list[AnyMessage], add_messages] + case_id: str + prompt: str + research: str + analysis: str + review: str + + +def _model(tools: list | None = None) -> ChatBedrockConverse: + llm = ChatBedrockConverse(model=MODEL_ID, region_name=REGION, temperature=0) + return llm.bind_tools(tools) if tools else llm + + +def _stage(state: CaseState, key: str, system: str, opening: str, + tools: list | None, field: str) -> dict: + """Run one turn of a stage. A new call opens with a fresh human turn; a call + that follows a ToolMessage continues the tool loop instead.""" + history = state[key] + new_messages: list[AnyMessage] = [] + if not history or not isinstance(history[-1], ToolMessage): + new_messages.append(HumanMessage(content=opening)) + reply = _model(tools).invoke([SystemMessage(content=system)] + history + new_messages) + new_messages.append(reply) + + update: dict = {key: new_messages} + if not reply.tool_calls: + update[field] = reply.text + return update + + +def research(state: CaseState) -> dict: + return _stage( + state, "research_messages", + "You gather the facts of the case.", + f"Case {state['case_id']}: {state['prompt']}", + [fetch_case_record], "research") + + +def analysis(state: CaseState) -> dict: + return _stage( + state, "analysis_messages", + "You weigh the options.", + f"Facts of case {state['case_id']}:\n{state['research']}\n\n{state['prompt']}", + [search_precedent], "analysis") + + +def review(state: CaseState) -> dict: + return _stage( + state, "review_messages", + "You recommend a decision.", + f"Analysis of case {state['case_id']}:\n{state['analysis']}\n\n{state['prompt']}", + None, "review") + + +def _route(messages_key: str): + """Scope tools_condition to one stage's message window.""" + + def condition(state: CaseState) -> str: + return tools_condition(state, messages_key=messages_key) + + return condition + + +def build_graph(db_path: str = "cases.db"): + """Wire the five nodes and compile with a checkpointer.""" + builder = StateGraph(CaseState) + builder.add_node("research", research) + builder.add_node("research_tools", + ToolNode([fetch_case_record], messages_key="research_messages")) + builder.add_node("analysis", analysis) + builder.add_node("analysis_tools", + ToolNode([search_precedent], messages_key="analysis_messages")) + builder.add_node("review", review) + + builder.add_edge(START, "research") + builder.add_conditional_edges("research", _route("research_messages"), + {"tools": "research_tools", END: "analysis"}) + builder.add_edge("research_tools", "research") + builder.add_conditional_edges("analysis", _route("analysis_messages"), + {"tools": "analysis_tools", END: "review"}) + builder.add_edge("analysis_tools", "analysis") + builder.add_edge("review", END) + + conn = sqlite3.connect(db_path, check_same_thread=False) + return builder.compile(checkpointer=SqliteSaver(conn)) + + +# Entrypoint: callers call run_case(case_id, prompt), and that call does not +# change through the migration. +def run_case(case_id: str, prompt: str, *, db_path: str = "cases.db", + recursion_limit: int = 25) -> dict: + graph = build_graph(db_path) + # External ID: case_id keys the conversation, and becomes the Strands session id. + config = {"configurable": {"thread_id": case_id}, "recursion_limit": recursion_limit} + return graph.invoke( + {"research_messages": [], "analysis_messages": [], "review_messages": [], + "case_id": case_id, "prompt": prompt, + "research": "", "analysis": "", "review": ""}, + config, + ) + + +def main() -> int: + case_id = sys.argv[1] if len(sys.argv) > 1 else "case-4127" + prompt = (sys.argv[2] if len(sys.argv) > 2 + else "Assess the dispute and recommend a decision.") + + result = run_case(case_id, prompt) + + counts = {k.removesuffix("_messages"): len(result[k]) + for k in ("research_messages", "analysis_messages", "review_messages")} + print(f"\nCase: {case_id}") + print(f"Messages: {counts}") + print(f"\nRecommendation:\n{result['review']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/migration/langgraph/requirements.txt b/python/migration/langgraph/requirements.txt new file mode 100644 index 00000000..eda77d06 --- /dev/null +++ b/python/migration/langgraph/requirements.txt @@ -0,0 +1,4 @@ +langgraph>=1.0 +langgraph-checkpoint-sqlite>=1.0 +langchain-aws>=1.0 +strands-agents diff --git a/python/migration/langgraph/strands_after.py b/python/migration/langgraph/strands_after.py new file mode 100644 index 00000000..36f1496c --- /dev/null +++ b/python/migration/langgraph/strands_after.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +# Case Triage in Strands (migration result) + +The "after" side of the [Migrate from LangGraph](https://strandsagents.com/docs/user-guide/migrate/langgraph/) +guide: the same three-stage case triage as `langgraph_before.py`, with +the same public entrypoint and the same external ID, rebuilt on Strands. + + pip install strands-agents + python strands_after.py [case-5501] + +Needs Python 3.10 or newer and AWS credentials with Bedrock access. Set +`CASE_MODEL_ID` to pin a model. +Sessions go to `./cases/` in the working directory; delete it to start a case +over. +""" + +from __future__ import annotations + +import os +import sys + +from strands import Agent, tool +from strands.multiagent import GraphBuilder +from strands.session import FileSessionManager + +# Stand-in for the record system a real deployment would query. +CASE_RECORDS = { + "case-4127": ( + "Case case-4127 (tenant dispute, filed 2026-03-02)\n" + "Summary: Tenant withheld two months rent citing an unrepaired heating " + "system. Landlord filed for possession.\n" + "Facts on file:\n" + "- Heating fault first reported by tenant on 2025-11-14.\n" + "- Landlord sent a contractor on 2025-12-20, 36 days later.\n" + "- Tenant withheld rent for January and February 2026.\n" + "- Local code requires habitable heat within 14 days of written notice." + ), + "case-5501": ( + "Case case-5501 (deposit withholding, filed 2026-05-19)\n" + "Summary: Landlord retained the full deposit for cleaning after a " + "14-month tenancy.\n" + "Facts on file:\n" + "- Move-out inspection recorded normal wear on carpets.\n" + "- No itemized deduction statement was sent within the statutory 21 days.\n" + "- Tenant provided dated move-out photographs." + ), +} + +PRECEDENTS = [ + ("repair delay rent withholding", + "P-118: Rent withholding upheld where the landlord exceeded the statutory " + "repair window after written notice."), + ("deposit itemization deadline", + "P-204: Failure to send an itemized deduction statement within the statutory " + "period forfeits the right to withhold."), + ("normal wear and tear", + "P-077: Ordinary carpet wear over a tenancy of more than twelve months is not " + "chargeable to the tenant."), +] + + +@tool +def fetch_case_record(case_id: str) -> str: + """Fetch the filed record for a case. + + Args: + case_id: Identifier of the case to fetch + """ + return CASE_RECORDS.get(case_id, f"No filed record exists for {case_id}.") + + +@tool +def search_precedent(question: str) -> str: + """Search prior decisions for relevant precedent. + + Args: + question: What to look for in prior decisions + """ + words = {w.strip(".,;:?").lower() for w in question.split()} + hits = [text for topic, text in PRECEDENTS if words & set(topic.split())] + return "\n".join(hits or [text for _, text in PRECEDENTS]) + + +# Entrypoint: callers call run_case(case_id, prompt) exactly as before. +def run_case(case_id: str, prompt: str, *, storage_dir: str = "./cases/", + max_nodes: int = 10): + # A string becomes a BedrockModel; None means the SDK default. + model = os.environ.get("CASE_MODEL_ID") + + researcher = Agent( + name="research", + system_prompt="You gather the facts of the case.", + tools=[fetch_case_record], + model=model, + ) + analyst = Agent( + name="analysis", + system_prompt="You weigh the options.", + tools=[search_precedent], + model=model, + ) + reviewer = Agent( + name="review", + system_prompt="You recommend a decision.", + model=model, + ) + + builder = GraphBuilder() + builder.add_node(researcher, "research") + builder.add_node(analyst, "analysis") + builder.add_node(reviewer, "review") + builder.add_edge("research", "analysis") + builder.add_edge("analysis", "review") + builder.set_entry_point("research") + # Caps node executions, not the tool loop inside each agent. Without a cap + # or a timeout the SDK logs a warning. + builder.set_max_node_executions(max_nodes) + + # External ID: case_id is the session id, so an interrupted graph resumes + # from it on the next call. + builder.set_session_manager( + FileSessionManager(session_id=case_id, storage_dir=storage_dir) + ) + + # The case id has to reach the agents, so it travels in the task text. + return builder.build()(f"Case {case_id}: {prompt}") + + +def main() -> int: + case_id = sys.argv[1] if len(sys.argv) > 1 else "case-4127" + prompt = (sys.argv[2] if len(sys.argv) > 2 + else "Assess the dispute and recommend a decision.") + + result = run_case(case_id, prompt) + + print(f"\nCase: {case_id}") + print(f"Status: {result.status}") + print(f"Order: {[node.node_id for node in result.execution_order]}") + print(f"Tokens: {result.accumulated_usage['totalTokens']}") + + review = result.results["review"].result + print(f"\nRecommendation:\n{review}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/typescript/migration/langgraph/langgraph-before/package.json b/typescript/migration/langgraph/langgraph-before/package.json new file mode 100644 index 00000000..c1a1a951 --- /dev/null +++ b/typescript/migration/langgraph/langgraph-before/package.json @@ -0,0 +1,26 @@ +{ + "name": "langgraph-migration-before-example", + "version": "1.0.0", + "description": "Case triage agent graph in LangGraph.js, the TypeScript starting point for migrating to Strands", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js" + }, + "dependencies": { + "@langchain/aws": "^1.4.5", + "@langchain/core": "^1.2.12", + "@langchain/langgraph": "^1.4.16", + "@langchain/langgraph-checkpoint-sqlite": "^1.0.4", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } +} diff --git a/typescript/migration/langgraph/langgraph-before/src/index.ts b/typescript/migration/langgraph/langgraph-before/src/index.ts new file mode 100644 index 00000000..f88828a7 --- /dev/null +++ b/typescript/migration/langgraph/langgraph-before/src/index.ts @@ -0,0 +1,239 @@ +/** + * # Case Triage in LangGraph.js (migration starting point) + * + * The "before" side of the Migrate from LangGraph guide, in TypeScript. Compare + * with the Strands result in `../strands-after/src/index.ts` and the Python + * starting point in `python/migration/langgraph/langgraph_before.py`. + * + * The graph has five nodes for three stages, because in a `StateGraph` a tool + * call is a node the graph routes to and back from. + * + * Each stage gets its own message window (`researchMessages` and so on). The + * guide's inline snippet uses one `messages` key for brevity. LangGraph.js's + * `ToolNode` reads the `messages` key only, so a small node hands it the + * stage's window instead; Python's `ToolNode` takes a `messages_key` argument. + * + * npm install && npm run build + * node dist/index.js [case-5501] + * + * Needs AWS credentials with Bedrock access. Set `CASE_MODEL_ID` to pin a model + * and `CASE_DB` to move the SQLite file from `./cases.db`. + * Delete that file to start a case over. + */ + +import { ChatBedrockConverse, type ChatBedrockConverseToolType } from '@langchain/aws' +import { + HumanMessage, + SystemMessage, + isToolMessage, + type BaseMessage, +} from '@langchain/core/messages' +import { tool, type StructuredToolInterface } from '@langchain/core/tools' +import { + END, + MessagesValue, + START, + StateGraph, + StateSchema, + type GraphNode, +} from '@langchain/langgraph' +import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite' +import { ToolNode, toolsCondition } from '@langchain/langgraph/prebuilt' +import { z } from 'zod' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const MODEL_ID = process.env.CASE_MODEL_ID ?? 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' +const REGION = process.env.AWS_REGION ?? 'us-east-1' + +// Stand-in for the record system a real deployment would query. +const CASE_RECORDS: Record = { + 'case-4127': [ + 'Case case-4127 (tenant dispute, filed 2026-03-02)', + 'Summary: Tenant withheld two months rent citing an unrepaired heating system. Landlord filed for possession.', + 'Facts on file:', + '- Heating fault first reported by tenant on 2025-11-14.', + '- Landlord sent a contractor on 2025-12-20, 36 days later.', + '- Tenant withheld rent for January and February 2026.', + '- Local code requires habitable heat within 14 days of written notice.', + ].join('\n'), + 'case-5501': [ + 'Case case-5501 (deposit withholding, filed 2026-05-19)', + 'Summary: Landlord retained the full deposit for cleaning after a 14-month tenancy.', + 'Facts on file:', + '- Move-out inspection recorded normal wear on carpets.', + '- No itemized deduction statement was sent within the statutory 21 days.', + '- Tenant provided dated move-out photographs.', + ].join('\n'), +} + +const PRECEDENTS: Array<[string, string]> = [ + ['repair delay rent withholding', + 'P-118: Rent withholding upheld where the landlord exceeded the statutory repair window after written notice.'], + ['deposit itemization deadline', + 'P-204: Failure to send an itemized deduction statement within the statutory period forfeits the right to withhold.'], + ['normal wear and tear', + 'P-077: Ordinary carpet wear over a tenancy of more than twelve months is not chargeable to the tenant.'], +] + +const fetchCaseRecord = tool( + ({ caseId }) => CASE_RECORDS[caseId] ?? `No filed record exists for ${caseId}.`, + { + name: 'fetch_case_record', + description: 'Fetch the filed record for a case.', + schema: z.object({ caseId: z.string().describe('Identifier of the case to fetch') }), + }, +) + +const searchPrecedent = tool( + ({ question }) => { + const words = new Set( + question.split(/\s+/).map((w) => w.replace(/^[.,;:?]+|[.,;:?]+$/g, '').toLowerCase()), + ) + const hits = PRECEDENTS + .filter(([topic]) => topic.split(' ').some((t) => words.has(t))) + .map(([, text]) => text) + return (hits.length ? hits : PRECEDENTS.map(([, text]) => text)).join('\n') + }, + { + name: 'search_precedent', + description: 'Search prior decisions for relevant precedent.', + schema: z.object({ question: z.string().describe('What to look for in prior decisions') }), + }, +) + +// One shared state object, as LangGraph expects. Each stage owns a message +// window; see the note in the file header. +const CaseState = new StateSchema({ + researchMessages: MessagesValue, + analysisMessages: MessagesValue, + reviewMessages: MessagesValue, + caseId: z.string(), + prompt: z.string(), + researchText: z.string(), + analysisText: z.string(), + reviewText: z.string(), +}) + +type State = typeof CaseState.State +type Update = typeof CaseState.Update +type WindowKey = 'researchMessages' | 'analysisMessages' | 'reviewMessages' +type TextKey = 'researchText' | 'analysisText' | 'reviewText' + +const llm = new ChatBedrockConverse({ model: MODEL_ID, region: REGION, temperature: 0 }) + +function model(tools?: ChatBedrockConverseToolType[]) { + return tools ? llm.bindTools(tools) : llm +} + +/** + * Run one turn of a stage. A new call opens with a fresh human turn; a call that + * follows a ToolMessage continues the tool loop instead. + */ +async function stage( + state: State, + key: WindowKey, + system: string, + opening: string, + tools: ChatBedrockConverseToolType[] | undefined, + field: TextKey, +): Promise { + const history = state[key] + const newMessages: BaseMessage[] = [] + if (!isToolMessage(history.at(-1))) { + newMessages.push(new HumanMessage(opening)) + } + const reply = await model(tools).invoke([new SystemMessage(system), ...history, ...newMessages]) + newMessages.push(reply) + + const update: Update = { [key]: newMessages } + if (!reply.tool_calls?.length) { + update[field] = reply.text + } + return update +} + +const research: GraphNode = (state) => + stage(state, 'researchMessages', 'You gather the facts of the case.', + `Case ${state.caseId}: ${state.prompt}`, [fetchCaseRecord], 'researchText') + +const analysis: GraphNode = (state) => + stage(state, 'analysisMessages', 'You weigh the options.', + `Facts of case ${state.caseId}:\n${state.researchText}\n\n${state.prompt}`, [searchPrecedent], 'analysisText') + +const review: GraphNode = (state) => + stage(state, 'reviewMessages', 'You recommend a decision.', + `Analysis of case ${state.caseId}:\n${state.analysisText}\n\n${state.prompt}`, undefined, 'reviewText') + +/** Run a ToolNode against one stage's window and write the results back to it. */ +function toolStage(key: WindowKey, tools: StructuredToolInterface[]): GraphNode { + const node = new ToolNode(tools) + // Only the window is passed, so tools that read other state fields would not see them. + return async (state) => ({ [key]: await node.invoke(state[key]) }) +} + +/** Scope toolsCondition to one stage's message window. */ +function route(key: WindowKey) { + return (state: State) => toolsCondition(state[key]) +} + +/** Wire the five nodes and compile with a checkpointer. */ +function buildGraph(dbPath: string) { + const builder = new StateGraph(CaseState) + .addNode('research', research) + .addNode('research_tools', toolStage('researchMessages', [fetchCaseRecord])) + .addNode('analysis', analysis) + .addNode('analysis_tools', toolStage('analysisMessages', [searchPrecedent])) + .addNode('review', review) + .addEdge(START, 'research') + .addConditionalEdges('research', route('researchMessages'), { tools: 'research_tools', [END]: 'analysis' }) + .addEdge('research_tools', 'research') + .addConditionalEdges('analysis', route('analysisMessages'), { tools: 'analysis_tools', [END]: 'review' }) + .addEdge('analysis_tools', 'analysis') + .addEdge('review', END) + + return builder.compile({ checkpointer: SqliteSaver.fromConnString(dbPath) }) +} + +// Entrypoint: callers call runCase(caseId, prompt), and that call does not +// change through the migration. +export async function runCase( + caseId: string, + prompt: string, + options: { dbPath?: string; recursionLimit?: number } = {}, +) { + const graph = buildGraph(options.dbPath ?? 'cases.db') + // External ID: caseId keys the conversation, and becomes the Strands session id. + const config = { configurable: { thread_id: caseId }, recursionLimit: options.recursionLimit ?? 25 } + return graph.invoke( + { + researchMessages: [], analysisMessages: [], reviewMessages: [], + caseId, prompt, researchText: '', analysisText: '', reviewText: '', + }, + config, + ) +} + +async function main(): Promise { + const caseId = process.argv[2] ?? 'case-4127' + const prompt = process.argv[3] ?? 'Assess the dispute and recommend a decision.' + + const result = await runCase(caseId, prompt, { dbPath: process.env.CASE_DB }) + + const counts = { + research: result.researchMessages.length, + analysis: result.analysisMessages.length, + review: result.reviewMessages.length, + } + console.log(`\nCase: ${caseId}`) + console.log(`Messages: ${JSON.stringify(counts)}`) + console.log(`\nRecommendation:\n${result.reviewText}`) + return 0 +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then((code) => process.exit(code)).catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/typescript/migration/langgraph/langgraph-before/tsconfig.json b/typescript/migration/langgraph/langgraph-before/tsconfig.json new file mode 100644 index 00000000..984b75d6 --- /dev/null +++ b/typescript/migration/langgraph/langgraph-before/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2022" + ], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/typescript/migration/langgraph/strands-after/package.json b/typescript/migration/langgraph/strands-after/package.json new file mode 100644 index 00000000..16b72324 --- /dev/null +++ b/typescript/migration/langgraph/strands-after/package.json @@ -0,0 +1,23 @@ +{ + "name": "langgraph-migration-example", + "version": "1.0.0", + "description": "Case triage agent graph in Strands, the TypeScript result of migrating from LangGraph", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js" + }, + "dependencies": { + "@strands-agents/sdk": "^1.19.0", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } +} diff --git a/typescript/migration/langgraph/strands-after/src/index.ts b/typescript/migration/langgraph/strands-after/src/index.ts new file mode 100644 index 00000000..f2e3ac23 --- /dev/null +++ b/typescript/migration/langgraph/strands-after/src/index.ts @@ -0,0 +1,166 @@ +/** + * Case Triage in Strands, TypeScript (migration result) + * + * The TypeScript counterpart of `python/migration/langgraph/strands_after.py`, + * and the "after" side of the Migrate from LangGraph guide. Same three stages, same + * public entrypoint, same external ID. + * + * npm install && npm run build + * node dist/index.js [case-5501] + * + * Needs AWS credentials with Bedrock access and AWS_REGION set. Set CASE_MODEL_ID + * to pin a model. + * Sessions go to `./cases/` in the working directory; delete it to start a case + * over. + */ + +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { Agent, Graph, SessionManager, tool } from '@strands-agents/sdk' +import { LocalFileStorage } from '@strands-agents/sdk/storage' +import { z } from 'zod' + +// Stand-in for the record system a real deployment would query. +const CASE_RECORDS: Record = { + 'case-4127': [ + 'Case case-4127 (tenant dispute, filed 2026-03-02)', + 'Summary: Tenant withheld two months rent citing an unrepaired heating system. Landlord filed for possession.', + 'Facts on file:', + '- Heating fault first reported by tenant on 2025-11-14.', + '- Landlord sent a contractor on 2025-12-20, 36 days later.', + '- Tenant withheld rent for January and February 2026.', + '- Local code requires habitable heat within 14 days of written notice.', + ].join('\n'), + 'case-5501': [ + 'Case case-5501 (deposit withholding, filed 2026-05-19)', + 'Summary: Landlord retained the full deposit for cleaning after a 14-month tenancy.', + 'Facts on file:', + '- Move-out inspection recorded normal wear on carpets.', + '- No itemized deduction statement was sent within the statutory 21 days.', + '- Tenant provided dated move-out photographs.', + ].join('\n'), +} + +const PRECEDENTS: Array<{ topic: string; text: string }> = [ + { + topic: 'repair delay rent withholding', + text: + 'P-118: Rent withholding upheld where the landlord exceeded the statutory ' + + 'repair window after written notice.', + }, + { + topic: 'deposit itemization deadline', + text: + 'P-204: Failure to send an itemized deduction statement within the statutory ' + + 'period forfeits the right to withhold.', + }, + { + topic: 'normal wear and tear', + text: + 'P-077: Ordinary carpet wear over a tenancy of more than twelve months is not ' + + 'chargeable to the tenant.', + }, +] + +const fetchCaseRecord = tool({ + name: 'fetch_case_record', + description: 'Fetch the filed record for a case', + inputSchema: z.object({ + caseId: z.string().describe('Identifier of the case to fetch'), + }), + callback: (input) => + CASE_RECORDS[input.caseId] ?? `No filed record exists for ${input.caseId}.`, +}) + +const searchPrecedent = tool({ + name: 'search_precedent', + description: 'Search prior decisions for relevant precedent', + inputSchema: z.object({ + question: z.string().describe('What to look for in prior decisions'), + }), + callback: (input) => { + // Strip punctuation from each end only, matching the Python example. + const words = new Set( + input.question + .toLowerCase() + .split(/\s+/) + .map((w) => w.replace(/^[.,;:?]+|[.,;:?]+$/g, '')), + ) + const hits = PRECEDENTS.filter((p) => p.topic.split(' ').some((t) => words.has(t))) + return (hits.length ? hits : PRECEDENTS).map((p) => p.text).join('\n') + }, +}) + +// Entrypoint: callers call runCase(caseId, prompt) exactly as before. +export async function runCase( + caseId: string, + prompt: string, + options: { storageDir?: string; maxSteps?: number } = {}, +) { + const { storageDir = './cases/', maxSteps = 10 } = options + // A string becomes a BedrockModel; undefined means the SDK default. + const model = process.env.CASE_MODEL_ID + + const researcher = new Agent({ + id: 'research', + systemPrompt: 'You gather the facts of the case.', + tools: [fetchCaseRecord], + model, + }) + const analyst = new Agent({ + id: 'analysis', + systemPrompt: 'You weigh the options.', + tools: [searchPrecedent], + model, + }) + const reviewer = new Agent({ + id: 'review', + systemPrompt: 'You recommend a decision.', + model, + }) + + const graph = new Graph({ + nodes: [researcher, analyst, reviewer], + edges: [ + ['research', 'analysis'], + ['analysis', 'review'], + ], + sources: ['research'], + // Caps node executions, not the tool loop inside each agent. Without a cap + // or a timeout the SDK logs a warning. + maxSteps, + // External ID: caseId is the session id, so an interrupted graph resumes + // from it on the next call. + sessionManager: new SessionManager({ + sessionId: caseId, + storage: new LocalFileStorage(storageDir), + }), + }) + + // The case id has to reach the agents, so it travels in the task text. + return graph.invoke(`Case ${caseId}: ${prompt}`) +} + +async function main(): Promise { + const caseId = process.argv[2] ?? 'case-4127' + const prompt = process.argv[3] ?? 'Assess the dispute and recommend a decision.' + + const result = await runCase(caseId, prompt) + + console.log(`\nCase: ${caseId}`) + console.log(`Status: ${result.status}`) + console.log(`Order: ${result.results.map((r) => r.nodeId).join(' -> ')}`) + console.log(`Tokens: ${result.usage.totalTokens}`) + + const text = result.content.find((b) => b.type === 'textBlock')?.text + console.log(`\nRecommendation:\n${text ?? '(none)'}`) + return 0 +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then((code) => process.exit(code)).catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/typescript/migration/langgraph/strands-after/tsconfig.json b/typescript/migration/langgraph/strands-after/tsconfig.json new file mode 100644 index 00000000..61da8a14 --- /dev/null +++ b/typescript/migration/langgraph/strands-after/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}