Functional API¶
The functional API is the Tulip's "agent as a task" shape — @task and @entrypoint decorators
that bring agent runs into the regular asyncio universe.
What it is¶
Two decorators:
| Decorator | What it does |
|---|---|
@task |
Wraps a coroutine that calls an Agent. Returns a Task you can await, gather, retry, time-out — anything asyncio gives you. |
@entrypoint |
Marks the top-level coroutine of a workflow. You await it like any coroutine; it also records the last run on .last_result / .get_result() for inspection. |
These are not a new orchestration runtime. They're a thin shim
that lets agents participate in plain asyncio. The point is to
compose with asyncio.gather, asyncio.wait_for, asyncio.Queue,
or anything else you already use.
When to use it¶
- ✅ You think in
async defandasyncio.gatheralready. - ✅ The flow is map/reduce over agents (enrich N IOCs in parallel).
- ✅ You want to mix agents with non-agent code — DB writes, HTTP calls, file I/O — in the same coroutine.
- ✅ Tooling like
tenacityretries,asyncio.timeout, or aasyncio.Queuescheduler already gives you the orchestration you need.
When NOT to use it¶
- ❌ You want inspectable, named control-flow with cycles or conditional branches → use StateGraph.
- ❌ You need per-node retry / cache policies as data → StateGraph.
- ❌ Different agents should decide who runs → use Orchestrator.
Code¶
import asyncio
from tulip.multiagent.functional import task, entrypoint
@task
async def enrich_ioc(ioc: dict) -> dict:
"""Run the triage agent against one indicator."""
return triage_agent.run_sync(f"Enrich {ioc['value']}.").message
@entrypoint
async def enrich_all(iocs: list[dict]) -> list[dict]:
"""Enrich every indicator in parallel; gather the results."""
return await asyncio.gather(*[enrich_ioc(i) for i in iocs])
scored = await enrich_all(indicators) # or: asyncio.run(enrich_all(indicators))
Map/reduce with retries and timeouts¶
Because tasks are plain coroutines, you compose with whatever the asyncio ecosystem provides:
from tenacity import retry, stop_after_attempt, wait_exponential
@task
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5))
async def enrich_ioc(ioc: dict) -> dict:
return triage_agent.run_sync(f"Enrich {ioc['value']}.").message
@entrypoint
async def enrich_all_with_deadline(iocs: list[dict]) -> list[dict]:
async with asyncio.timeout(60): # 60s wall-clock cap
return await asyncio.gather(*[enrich_ioc(i) for i in iocs])
Tasks calling tasks¶
Tasks compose. An @entrypoint workflow can call other @tasks
including parallel batches inside sequential phases:
@task
async def prioritize_alerts(queue: list[dict]) -> list[dict]:
return triage_agent.run_sync(f"Pick the top 5 from {len(queue)}.").message
@task
async def enrich(alert: dict) -> dict:
return triage_agent.run_sync(f"Enrich {alert['ioc']}.").message
@entrypoint
async def end_to_end(queue: list[dict]) -> dict:
shortlisted = await prioritize_alerts(queue) # phase 1
scored = await asyncio.gather(*[enrich(a) for a in shortlisted]) # phase 2 (parallel)
final = containment_agent.run_sync(f"Recommend containment from: {scored}").message # phase 3
return final
Notebooks¶
notebook_23_functional_api.py—@taskand@entrypointend-to-end.notebook_30_map_reduce_code_review.py— same map/reduce shape, written as a graph withSendinstead. Useful as the "graph version" comparison when you're choosing between functional and StateGraph for a fan-out workload.
Source¶
multiagent/functional.py
— task, entrypoint, TaskResult, EntrypointResult.
See also¶
- Multi-agent overview — pick a shape.
- StateGraph — for the same fan-out as data with inspectable retry/cache policies.
- Composition — for the same shapes via
ParallelPipeline.