Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,36 @@ def postmortem(incident: str) -> str:

A method can also run on a live thread whose calls share history, have its proposals checked by a gate that runs as its post-condition, or read memory that the optimizer can update. See the [methods documentation](docs/methods.md) for details.

## Teams

The `ai_functions.experimental.teams` module puts a lead AI Function over a few live members. The lead holds every member as a typed tool and decides whom to ask, and each member keeps its own conversation across the lead's asks. Hooks add everything else, such as a review of the answer (see `examples/teams/status_desk.py`):

```python
from ai_functions.experimental.teams import Accept, FunctionMember, MethodMember, Revise, Team
from ai_functions.runtime import Budget, Cost


class HouseStyle:
def on_answer(self, work, answer):
if "Resolved at" in answer:
return Accept()
return Revise(feedback='End with the restore time as "Resolved at HH:MM UTC".', cap=2)


team = Team(
status_update,
members=[
MethodMember(analyst, "timeline", ask_estimate=Cost(dollars=0.01)),
FunctionMember(check_claim, name="skeptic", ask_estimate=Cost(dollars=0.01)),
],
hooks=[HouseStyle()],
)
run = await team.run("Payment errors on checkout this morning; the issue is fixed.", budget=Budget(Cost(dollars=0.50)))
print(run.answer)
```

A `Revise` sends the lead back to work with the feedback, and a revised answer goes back through every reviewer before it ships. The run's transcript records every member call and revision as a typed record, and `budget=` bounds what the run may spend in tokens, dollars or both, with each member reserving its `ask_estimate` before it's asked. See the [teams documentation](docs/teams.md) for details.

## Security

Code execution is off by default. The `"local"` mode validates generated code with AST checks, restricts imports to the allowlist you pass in, and applies timeouts. But it is not a sandbox: it cannot stop resource exhaustion (an infinite loop, runaway memory allocation) and offers no process-level isolation. For production, run AI Functions inside a container or other isolated environment, which adds the process isolation and resource limits that `"local"` mode cannot provide. For untrusted input, use `"disabled"` mode.
Expand Down
211 changes: 211 additions & 0 deletions docs/teams.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Lean automatically; see [Verified](#verified) for native compilation setup.
| Remember information and improve results from feedback | [Memory](#memory) |
| Choose models according to cost and expected success | [Economics](#economics) |
| Give an object AI methods and hand them to other agents | [Methods](#methods) |
| Lead a team of agents, review its answer, and bound its cost | [Teams](#teams) |
| Use Claude Code or Kiro as a thread | [Integrations](#integrations) |
| Verify the agent's work or compile a verified implementation | [Verified](#verified) |
| Explore a larger learning loop or benchmark | [Projects](#projects) |
Expand Down Expand Up @@ -167,6 +168,16 @@ with a gate. See [the methods guide](../docs/methods.md).
| [Analyst team](methods/analyst_team.py) | Call a method once, hand every method to an orchestrator as a typed tool, and keep one method on a live thread. | `uv run methods/analyst_team.py` |
| [Gated proposer](methods/gated_proposer.py) | Check each proposal against a policy cap, retry with the gate's report, and filter one-shot proposals from forked threads. | `uv run methods/gated_proposer.py` |

## Teams

Put a lead AI function over a few live members it calls as typed tools, review
the lead's answer with a hook, and run the team under a budget. See
[the teams guide](../docs/teams.md).

| Example | What it teaches | Run |
| --- | --- | --- |
| [Status desk](teams/status_desk.py) | Hold a method member and a function member as the lead's tools, send the first draft back with a hook, and read the transcript and the run's cost under a dollar budget. | `uv run teams/status_desk.py` |

## Integrations

These examples run external agent sessions as threads. Run them from
Expand Down
151 changes: 151 additions & 0 deletions examples/teams/status_desk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Teams: a lead over live members, with a hook that reviews the answer.

An incident desk writes the customer status update for a payment outage. The
lead is an ordinary ``@ai_function``. It holds two members as typed tools: an
analyst's ``timeline`` method on a live thread (``MethodMember``) and a
skeptic function that checks one claim against the notes (``FunctionMember``).
Each member keeps its own conversation across the lead's questions.

``HouseStyle`` is a hook. Its ``on_answer`` reads the lead's draft and returns
``Accept()`` or ``Revise(feedback=...)``, which sends the lead back to work with
the feedback. The team runs under a budget in dollars, and the result carries
the answer, a transcript of every member call and revision, and what the run
cost.
"""

import asyncio
import re

from example_helpers import models
from example_helpers.utils import display, rule
from pydantic import BaseModel, Field

from ai_functions import ai_function
from ai_functions.experimental.methods import MethodAgent, ai_method
from ai_functions.experimental.teams import (
Accept,
FunctionMember,
MemberCall,
MethodMember,
Revise,
Revision,
RevisionCap,
Team,
Workspace,
)
from ai_functions.runtime import Budget, Cost

NOTES = """\
09:02 UTC deploy 4172 rolls out to checkout: new payment client, retry limit changed from 5 to 0
09:05 UTC p99 latency on /pay climbs from 300 ms to 2.1 s
09:07 UTC error rate on /pay reaches 4%; the card processor reports no incidents on its side
09:12 UTC on-call rolls back deploy 4172
09:15 UTC latency and error rate on /pay are back to baseline
"""


class Finding(BaseModel):
answer: str = Field(description="The answer, in one or two sentences.")
evidence: list[str] = Field(description="The note lines the answer rests on, quoted exactly.")


class Analyst(MethodAgent):
"""The analyst for one service. Its notes live on ``self`` and reach every prompt."""

model = models.small

def __init__(self, service: str, notes: str) -> None:
self.service = service
self.notes = notes

@ai_method(Finding, description="Answer a question about when things happened")
def timeline(self, question: str) -> Finding:
"""You're the analyst for the {self.service} service. Answer from these notes only:

{self.notes}
Question: {question}
"""


# The body returns the prompt, so it can put the module's notes in front of the claim.
@ai_function[str](model=models.small)
def check_claim(claim: str) -> str:
return (
'Say whether these incident notes support this claim. Reply "supported" or "unsupported", '
f"then quote the note line that decides it.\n\nNotes:\n{NOTES}\nClaim: {claim}"
)


DESK = """\
You write customer status updates for incidents, in at most three sentences. Ask the analyst for
the facts before you write, and have the skeptic check your root-cause claim. Customers read the
update, so leave out deploy numbers and internal names."""


# The team's request lands in the lead's first parameter. A reviewer's feedback lands there too,
# on the same live thread, so the standing instructions live in the system prompt and the
# docstring is only the request: a revision then reads as a follow-up to the lead's own draft.
@ai_function(model=models.medium, system_prompt=DESK)
def status_update(incident: str) -> str:
"""{incident}"""


class HouseStyle:
"""Send the update back until it states the restore time the way the status page prints it."""

RESOLVED = re.compile(r"Resolved at \d{2}:\d{2} UTC")

def on_answer(self, work: Workspace, answer: str) -> Accept | Revise:
if self.RESOLVED.search(str(answer)):
return Accept()
return Revise(
feedback='End the update with the restore time in the form "Resolved at HH:MM UTC".',
cap=2,
)


def _short(text: str, limit: int = 110) -> str:
flat = " ".join(text.split())
return flat if len(flat) <= limit else flat[: limit - 3] + "..."


async def main() -> None:
analyst = Analyst("checkout", NOTES)
team = Team(
status_update,
members=[
# The lead sees this member as the `analyst_timeline` tool. Under a budget, each ask
# reserves the member's estimate until the member has measured an ask of its own.
MethodMember(analyst, "timeline", ask_estimate=Cost(dollars=0.01)),
FunctionMember(check_claim, name="skeptic", ask_estimate=Cost(dollars=0.01)),
],
hooks=[HouseStyle()],
)

rule("One team run under a $0.50 budget")
run = await team.run(
"Payment errors on checkout this morning; the issue is fixed.",
budget=Budget(Cost(dollars=0.50)),
)
display("Status update", run.answer)

rule("The transcript: written by the team, not reported by the model")
for entry in run.transcript:
if isinstance(entry, MemberCall):
print(f"member {entry.call_id}")
print(f" asked: {_short(entry.request)}")
print(f" answered: {_short(entry.answer)}")
elif isinstance(entry, Revision):
print(f"revise {entry.hook} round {entry.round}: {entry.feedback}")
elif isinstance(entry, RevisionCap):
print(f"revise_cap {entry.hook} after {entry.rounds} rounds; the last answer passed through")
else:
print(f"{entry.kind} {entry.data}")

rule("What the run cost")
dollars = "unpriced" if run.cost.dollars is None else f"${run.cost.dollars:.4f}"
print(f"{run.cost.tokens} tokens, {dollars}")


if __name__ == "__main__":
asyncio.run(main())
135 changes: 135 additions & 0 deletions spec/ai_functions/experimental/teams/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Teams: a lead over live members, with hooks for everything that is not that.

The core (:mod:`.core`) owns exactly what every team needs — spawn the
members, run the lead with the members as typed tools, review the answer
through a bounded loop, retire everybody — and every other capability arrives
as a hook. The contracts layer (:mod:`.contracts`, :mod:`.types`) is the
vocabulary those parties exchange and nothing else; :class:`Team` and its
:class:`TeamRun` report are the composition built on it.
:class:`FunctionMember` is the ``AIFunction``-backed member shape and
:class:`MethodMember` the ``MethodAgent``-backed one.

Four protocols and one state plane. :class:`Member` is what a member
promises: a name, and the three verbs the core performs on it — ``spawn``,
``ask``, ``retire``. :class:`TeamHook` is what a hook may implement; every
method is optional and absence means skip, so a team's ``hooks`` are typed
:data:`PartialTeamHook`, any object with at least one of those methods.
:class:`Equippable` is the optional seam a member exposes so hook-contributed
tools can ride into its cycles, and :class:`Described` the optional seam a
member's ``description`` reaches the lead through, as part of the tool the
lead calls it by. :class:`Workspace` is one run's shared state, handed to
every hook method as one argument so a new field breaks no existing hook.

The answer loop speaks in two verdicts. :class:`Accept` passes the answer on;
:class:`Revise` re-runs the lead with feedback under a cap the hook itself
chooses. What the loop and the members did is written to the transcript as
frozen records — a :class:`MemberCall` per question the lead asked a member,
a :class:`Revision` per re-run, a :class:`RevisionCap` when a hook's budget ran
out, a :class:`Note` for anything a hook wants remembered — each recorded by
the wire that carried the event, never reported by the model.

The protocols compose with this repository's runtime: ``spawn`` takes an
:class:`~ai_functions.protocols.Coordinator` and a
:class:`~ai_functions.types.ThreadId` parent, and the lead's handle on the
workspace is a :class:`~ai_functions.handle.ThreadHandle`. A run can be
bounded by a :class:`~ai_functions.runtime.budget.Budget` in tokens, dollars
or both.

Invariants:
T1 — a member is a name and three verbs. ``spawn`` puts it on a live
thread as a child of the given parent and returns something with an
``id``; ``ask`` runs one cycle and returns its result; ``retire`` tears the
thread down and is idempotent, because an unwind loop must not crash
mid-unwind.

T2 — a verdict is exactly one of ``Accept`` or ``Revise``. ``Accept``
carries nothing and refuses extra fields; ``Revise`` carries feedback and
a cap. Anything else returned from ``on_answer`` is a wiring bug for the
core to raise on, never a silent pass.

T3 — ``Revise.cap`` is the hook's own budget for this finding, read off
the latest verdict. A negative cap is a construction error because it
would silently behave as zero. Exhaustion is not an error: the answer
passes on and the transcript records that the cap, not the hook, ended
the loop. ``Revise.mandatory`` (default off) makes that exhaustion a
blocked ``TeamRun`` instead of ordinary success (core C8).

T4 — a ``Workspace`` is one run and has identity, not value equality: two
runs never share one, two workspaces built from the same inputs are not
equal, and per-run state may key by the workspace itself or its
``run_id``. ``Workspace.calls`` counts each member's asks, so
``MemberCall.call_id`` is ``run_id:member:n``.

T5 — transcript entries are frozen records with a ``kind``. The core owns
``member``, ``revise``, and ``revise_cap``; a hook's ``Note`` may not reuse
them, so a reader filtering on kind never mistakes a hook's entry for the
core's.

T6 — every ``TeamHook`` method is optional, and presence is checked with
``getattr``, never ``isinstance``. ``tools_for_lead`` and
``tools_for_member`` are synchronous; the lifecycle methods may be sync or
async and the core awaits whichever it finds.

T7 — the contracts and core modules import nothing from any other
experimental package; :mod:`.members` reaches sideways only into
``experimental/methods``. Methods never imports teams.

T8 — a member's description reaches the lead through one seam.
``Described`` is the seam (``description: str | None``); the core reads it
by ``isinstance`` and appends the text to the member tool's generic
description, and a ``None`` or blank description, or a member without the
seam, leaves the generic text byte for byte.

T9 — the ask in flight is readable. The core sets a :class:`CallContext`
around every member ask and :func:`current_call` reads it back on the
same task, so a member that opens work of its own names that work after
the ask that caused it without a new argument on ``Member``.
"""

from __future__ import annotations

from .contracts import Described, Equippable, Member, PartialTeamHook, Spawned, TeamHook, Workspace
from .core import CallContext, StopRun, Team, TeamRun, WiringError, current_call
from .members import FunctionMember, MethodMember
from .types import (
CORE_TRANSCRIPT_KINDS,
Accept,
MemberCall,
Note,
Revise,
Revision,
RevisionCap,
RunId,
TeamError,
TranscriptEntry,
Verdict,
)

__all__ = [
"Accept",
"CORE_TRANSCRIPT_KINDS",
"CallContext",
"Described",
"Equippable",
"FunctionMember",
"Member",
"MemberCall",
"MethodMember",
"Note",
"PartialTeamHook",
"Revise",
"Revision",
"RevisionCap",
"RunId",
"Spawned",
"StopRun",
"Team",
"TeamError",
"TeamHook",
"TeamRun",
"TranscriptEntry",
"Verdict",
"WiringError",
"Workspace",
"current_call",
]
Loading