Skip to content

feat(experimental.teams): add teams of a lead over live members - #67

Open
laithalsaadoon wants to merge 1 commit into
strands-labs:mainfrom
laithalsaadoon:upstream/teams-core
Open

laithalsaadoon wants to merge 1 commit into
strands-labs:mainfrom
laithalsaadoon:upstream/teams-core

Conversation

@laithalsaadoon

Copy link
Copy Markdown
Contributor

Summary

Adds ai_functions.experimental.teams, which puts one lead AI function over a few live members. The lead holds each member as a typed tool and decides whom to ask and what to ask them. Each member keeps its own conversation across the lead's asks. Hooks add everything else: extra tools, a rewritten request, a check before every ask, and a review of the answer.

@ai_function
def research(topic: str) -> str:
    """Report what you know about {topic}, in three sentences."""


@ai_function(system_prompt="Answer the question. Ask the scout before you decide.")
def answer(question: str) -> str:
    """{question}"""


class Cited:
    def on_answer(self, work: Workspace, answer: str) -> Accept | Revise:
        return Accept() if "scout" in answer else Revise(feedback="Say what the scout told you.")


team = Team(answer, [FunctionMember(research, name="scout", ask_estimate=Cost(dollars=0.01))], hooks=[Cited()])
run = await team.run("Why are there two tides a day?", budget=Budget(Cost(dollars=0.50)))

run.answer is the lead's final answer, and run.transcript lists every member call and revision as a typed record, written by the code that carried it rather than reported by the model. run.cost is the run's measured spend in tokens and dollars. Every thread the run spawned is retired by the time run returns, on every path out.

To bound that spend, the PR also adds Budget, Cost and a static price table to ai_functions.runtime, and names the model on each token usage event so the table can price it. The package builds MethodMember on ai_functions.experimental.methods and otherwise uses only public core API.

What's in it

  • core: Team(lead, members, hooks=(), *, ask_timeout_s=None) and Team.run(request, coordinator=None, *, parent_id=None, run_id=None, budget=None) -> TeamRun. A run spawns the members as children of the lead's thread, runs the lead with each member as a tool, walks the hooks over each answer, and retires everybody in a finally. With no coordinator, it stands up a private InMemoryCoordinator and closes it when the run ends. One instance runs one run at a time, so a second concurrent run raises WiringError before anything spawns.
  • The answer loop: a hook's on_answer returns Accept() or Revise(feedback=..., cap=2, mandatory=False). A revised answer goes back through every reviewer before it ships, each reviewer's revisions are capped, and a mandatory reviewer that runs out of revisions marks the run blocked.
  • Stopping: Team.cancel(reason) stops a live run, and a before_ask hook can raise StopRun(reason) to stop it before an ask. Either way the lead stops at the tool boundary, the members retire, the teardown hooks run, and run returns TeamRun(cancelled=True). ask_timeout_s bounds each member ask, and a member past its deadline is retired and isn't asked again in that run.
  • members: FunctionMember wraps an AIFunction, and MethodMember wraps one @ai_method of a MethodAgent. Both spawn without list_threads and send_message, so the lead's tool is the only way to reach a member.
  • contracts: the Member, TeamHook, Equippable and Described protocols, and the Workspace that every hook method receives. Any object with a name and spawn, ask and retire is a member.
  • types: the transcript records MemberCall, Revision, RevisionCap and Note, the Accept and Revise verdicts, and TeamError.
  • runtime.budget: Cost in tokens, dollars or both, and Budget with reservations, nested children, and an on_unknown policy for work that brings no estimate. Under Team.run(budget=...) the run holds one charge for its measured spend, and each member ask reserves the member's estimate before it runs. A spent budget refuses the next ask, and the lead reads the refusal as error: text, so it can finish with what it has.
  • runtime.prices: ModelPrice, PriceTable and PRICES, list prices per million tokens for current Claude models as published on 2026-10-21, with the Bedrock regional premium on geographic inference profiles and the higher rates Claude Haiku 5.5 charges for a prompt over 100,000 tokens. InMemoryCoordinator(prices=) carries the table. A model the table doesn't know has no price rather than a zero one, and a team refuses further asks under a dollar budget once it has spent unpriced usage.
  • Core changes: TokenUsageEvent gains model, the id of the model that spent the tokens, which AIThread fills in from the Strands model's config. InMemoryCoordinator takes prices= and exposes the table as prices.

The docstrings list the invariants: C1 to C14 for core, M1 to M6 for members, T1 to T9 for the package, BU1 to BU5 for budgets, and PR1 to PR5 for prices.

Docs and examples

  • docs/teams.md covers a lead, its members and a run, writing your own member, hooks, Accept and Revise, the transcript, budgets and deadlines, and cancelling a run, and ends with an errors table.
  • README.md gains a "Teams" section that links to it.
  • examples/README.md gains a Teams section with a row for the example.
  • examples/teams/status_desk.py has an incident desk write a customer status update for a payment outage. The lead asks an analyst's timeline method on a live thread and a skeptic function that checks one root-cause claim. A HouseStyle hook sends the draft back until it ends with the restore time, and the run prints its transcript and cost under a $0.50 budget. The lead uses example_helpers.models.medium and the members use models.small.

Testing

hatch run lint, hatch run check-spec and hatch run test pass on Python 3.12 (995 passed, 2 skipped). The PR adds 256 tests, and they run offline: scripted models drive the lead and the members, and the assertions read what each model received, the transcript, and the coordinator's event log. tests/teams/test_teams_ai_thread_contract.py pins the runtime behaviors the package relies on (one config_hook per cycle that replaces the tools, coordinator tools appended after it, parent_id on spawned threads, and a teardown that drops the thread's record at once), so a change to any of them fails there first. tests/teams/test_teams_cancel.py covers a stop at a tool boundary: after a StopRun, the lead's model isn't called again with the stopped ask's missing result.

On the final code, the example ran twice against Bedrock in us-east-1, with the lead on the Strands default model (global.anthropic.claude-sonnet-4-6 in strands-agents 1.62.0) and the members on global.anthropic.claude-haiku-4-5-20251001-v1:0. In both runs the lead asked the analyst and the skeptic once each, HouseStyle sent the first draft back for the restore time, and the second draft passed. The runs spent 9,003 tokens ($0.0269) and 9,087 tokens ($0.0272) against the $0.50 budget. The skeptic called the root-cause claim unsupported in the first run and supported in the second, and each update followed it: the first named only "a software change", and the second named the retry limit set to zero.

ruff format --check reports one file, src/ai_functions/memory/base.py, which is already unformatted on main.

Follow-ups

Two PRs build on this one. The first adds the review and worklog hooks and Rounds; the second adds subagents and nested teams, so a lead can create members during a run (create_subagent, create_subagent_inline, create_subteam, delegate, dismiss) under a headcount cap and the run's budget.

Adds ai_functions.experimental.teams: a lead AI function holds a few
live members as typed tools, and hooks add everything else.

- core: Team spawns the members as children of the lead's thread,
  runs the lead with each member as a tool, walks the hooks over the
  answer, and retires everybody on every path. Accept and Revise
  verdicts drive a bounded answer loop that sends a revised answer
  back through every reviewer before it ships, and a mandatory
  reviewer that runs out of revisions blocks the result. The
  transcript records every member call and revision as a typed
  record. Team(ask_timeout_s=) bounds each member ask, and
  Team.cancel stops a live run.
- members: FunctionMember wraps an AIFunction and MethodMember one
  @ai_method of a MethodAgent. Both spawn without list_threads and
  send_message, so the lead's tool is the only way to reach a member.
- contracts: the Member, TeamHook, Equippable and Described protocols
  and the Workspace every hook method receives.
- runtime: Budget and Cost bound work in tokens, dollars or both,
  with reservations and nested budgets. PRICES and PriceTable price
  token usage by model, and InMemoryCoordinator(prices=) carries the
  table. TokenUsageEvent gains the id of the model that spent it,
  which AIThread fills in. Team.run(budget=) charges the run's
  measured spend and reserves each member ask at the member's
  estimate.

Adds docs/teams.md, a README section, and
examples/teams/status_desk.py.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant