Hardening Strands agent model calls with async-combinators.
Strands' only built-in resilience is a bespoke retry mechanism that retries
throttling errors only; there is no timeout and no rate limiting at the model
layer. This example wraps a Strands OpenAIModel with the stream combinators
withRateLimit and withTimeout, and drives Strands' retry loop with the
promise combinator withRetry through a modelCallDriver extension point
added to the framework (in neu-se/harness-sdk,
branch adopt-async-combinators) — a small, targeted change that lets an
application opt into combinator-driven retry while preserving Strands' native
per-attempt observability: its BeforeModelCallEvent/AfterModelCallEvent
hooks still fire once per attempt, not once per call.
A small interactive "geography & math" agent (src/Agent.ts),
with a handful of tools unrelated to the case study (country lookup, unit
conversion, arithmetic, file read/write, shell commands). The part that
matters is src/ResilientOpenAIModel.ts: a
subclass of Strands' OpenAIModel that composes withRateLimit and
withTimeout around stream(), plus a resilientRetryDriver that supplies
withRetry as the agent's modelCallDriver.
npm install # also builds the harness-sdk fork -- see below
cp .env.example .env
# edit .env and set OPENROUTER_LLM_API_KEY (get one at https://openrouter.ai/keys)
npm run build
node --env-file=.env dist/Agent.js@strands-agents/sdk is installed directly from the fork
(github:neu-se/harness-sdk#adopt-async-combinators). Because that's a private
npm workspace monorepo, not a publishable package, a postinstall script
builds the strands-ts workspace after install and reshapes it in place so
@strands-agents/sdk resolves normally — no local sibling checkout required.
The [LOG] lines are added by this example, not part of Strands, to make the
combinators' activity visible: which ones are enabled at startup, and every
model call as it happens.
$ npm start
> async-combinators-strands-example@1.0.0 start
> node dist/Agent.js
[LOG] withRateLimit enabled: intervalMs=500
[LOG] withTimeout enabled: timeoutMs=15000
[LOG] resilientRetryDriver created: maxAttempts=3
Geography & Math Agent ready. Type your question or "exit" to quit.
You: How large is France compared to the UK and Spain?
[LOG] ResilientOpenAIModel.stream() called at 2026-08-12T23:52:30.794Z
I'll look up the areas of France, the UK, and Spain so I can compare them.
⏳ get_country_info
🔧 Tool #1: get_country_info
✓ Tool completed
[LOG] ResilientOpenAIModel.stream() called at 2026-08-12T23:52:32.548Z
Now I have all three countries' information. Let me compare France's size to the UK and Spain:
⏳ calculate
🔧 Tool #2: calculate
✓ Tool completed
[LOG] ResilientOpenAIModel.stream() called at 2026-08-12T23:52:34.262Z
⏳ calculate
🔧 Tool #3: calculate
✓ Tool completed
[LOG] ResilientOpenAIModel.stream() called at 2026-08-12T23:52:35.182Z
Here's how France compares to the UK and Spain:
**France's Area: 643,801 km²**
- **UK's Area: 243,610 km²**
- **Spain's Area: 505,990 km²**
**Comparisons:**
- **France vs UK:** France is about **2.64 times larger** than the UK
- **France vs Spain:** France is about **1.27 times larger** than Spain
So France is significantly larger than the UK (more than 2.5 times), but only
moderately larger than Spain. Spain is actually quite large — it's about 78%
the size of France.
You: exit
Bye!
Each [LOG] ResilientOpenAIModel.stream() called line is one model turn:
withRateLimit paces the start of each by at least intervalMs, withTimeout
bounds each to timeoutMs, and resilientRetryDriver retries — visibly, via
its own [LOG] line — a call that throttles or times out.