Systems¶
Systems contain the logic that operates on entities with specific component combinations. While Components define what entities are, systems define how they behave.
Overview¶
Systems are functions (or async functions) that query entities by their components and apply transformations. AgentECS uses access pattern declarations for validation and documentation, with optional declarations for flexibility.
Key Characteristics:
- Function-Based: Systems are decorated functions, not classes
- Optional Declarations: Declare
readsandwritesfor validation, or omit for full access - Snapshot Isolation: Systems in the same execution group see a consistent worldview
- Async-First: Systems can be sync or async, framework handles both seamlessly
graph TD
A[System] -->|Declares| B[reads: Task, TokenBudget]
A -->|Declares| C[writes: Task, TokenBudget]
A -->|Queries| D[World]
D -->|Returns| E[Entities with Components]
A -->|Updates| F[Write Buffer]
F -->|Applied at| G[Group Boundary]
style A fill:#c5e1a5
style D fill:#e1f5fe
style F fill:#ffecb3
Systems are Pure (Within an Execution Group)
Systems read from storage and write to a buffer. Changes aren't visible to other systems in the same group until the group boundary, enabling parallelization without race conditions. Subsequent groups see applied changes.
Basic Declaration¶
Systems are defined using the @system decorator:
from agentecs import system, ScopedAccess
@system(reads=(Task, TokenBudget), writes=(Task, TokenBudget))
def process_tasks(world: ScopedAccess) -> None:
"""Process tasks using available tokens."""
for entity, task, budget in world(Task, TokenBudget):
if task.status == "pending" and budget.available >= 100:
world[entity, Task] = Task(task.description, "completed")
world[entity, TokenBudget] = TokenBudget(
available=budget.available - 100,
used=budget.used + 100
)
System Function Signature¶
Required Parameter:
- world: ScopedAccess - Provides controlled access to the world
Optional Return:
- None - No explicit changes (use world[entity, Type] = value)
- SystemResult - Explicit result object
- dict[EntityId, dict[type, Any]] - Entity → components mapping
- dict[EntityId, Any] - Entity → single component (type inferred)
- list[tuple[EntityId, Any]] - List of (entity, component) pairs
@system(reads=(Task, TokenBudget), writes=(Task, TokenBudget))
def process_tasks(world: ScopedAccess) -> None:
for entity, task, budget in world(Task, TokenBudget):
if task.status == "pending" and budget.available >= 100:
world[entity, Task] = Task(task.description, "completed")
world[entity, TokenBudget] = TokenBudget(
available=budget.available - 100,
used=budget.used + 100
)
@system(reads=(Task, TokenBudget), writes=(Task, TokenBudget))
def process_tasks(world: ScopedAccess) -> dict[EntityId, dict[type, Any]]:
return {
entity: {
Task: Task(task.description, "completed"),
TokenBudget: TokenBudget(budget.available - 100, budget.used + 100)
}
for entity, task, budget in world(Task, TokenBudget)
if task.status == "pending" and budget.available >= 100
}
from agentecs import SystemResult
@system(reads=(Task, TokenBudget), writes=(Task, TokenBudget))
def process_tasks(world: ScopedAccess) -> SystemResult:
result = SystemResult()
for entity, task, budget in world(Task, TokenBudget):
if task.status == "pending" and budget.available >= 100:
if entity not in result.updates:
result.updates[entity] = {}
result.updates[entity][Task] = Task(task.description, "completed")
result.updates[entity][TokenBudget] = TokenBudget(
available=budget.available - 100,
used=budget.used + 100
)
return result
Prefer Direct Updates
Using world[entity, Type] = value is more ergonomic and readable than returning dicts or SystemResult objects. Save return values for pure systems or when you need fine-grained control.
Access Pattern Declaration¶
Systems can optionally declare which component types they read and write:
@system(
reads=(Task, TokenBudget, Context), # Can read these types
writes=(Task, Context), # Can write these types
)
def my_system(world: ScopedAccess) -> None:
pass
Access Declarations Are Optional
For quick prototyping, omit declarations entirely:
@system() # Full access, runs in parallel with other systems
def quick_prototype(world: ScopedAccess) -> None:
# Can read/write any component
pass
Defaulting rules are explicit:
@system()(both omitted) -> full read and write access- If one side is declared and the other omitted, the omitted side becomes no access
writes=()is an explicit no-write declaration@system.readonly()defaults to full read access, writes are always blocked
When to Declare Access
- Skip declarations for quick prototyping or simple scripts
- Add declarations when you want validation and documentation
- Use dev mode when debugging (runs alone, easier to reason about)
Write Implies Read
If a system declares a type in writes, it automatically has read access to that type. You don't need to list it in both.
Querying Entities and Components¶
Systems query entities using the world(ComponentType, ...) syntax:
Query Syntax¶
Basic Multi-Component Query:
@system(reads=(Task, TokenBudget), writes=(Task, TokenBudget))
def process_tasks(world: ScopedAccess) -> None:
# Iterate entities with BOTH Task AND TokenBudget
for entity, task, budget in world(Task, TokenBudget):
# task and budget are copies - must write back changes
if task.status == "pending" and budget.available >= 100:
print(f"Entity {entity} processing: {task.description}")
world[entity, Task] = Task(task.description, "completed")
world[entity, TokenBudget] = TokenBudget(
available=budget.available - 100,
used=budget.used + 100
)
Single-Component Query:
@system(reads=(TokenBudget,), writes=(TokenBudget,))
def decay_budget(world: ScopedAccess) -> None:
for entity, budget in world(TokenBudget):
world[entity, TokenBudget] = TokenBudget(
available=budget.available - 1,
used=budget.used + 1
)
Entity Handle for Repeated Access:
@system(reads=(Position, Velocity, Health), writes=(Position, Health))
def complex_system(world: ScopedAccess) -> None:
for entity_id, pos in world(Position):
e = world.entity(entity_id) # Get EntityHandle
# Dict-style access
if Velocity in e:
vel = e[Velocity]
e[Position] = Position(pos.x + vel.dx, pos.y + vel.dy)
if Health in e:
e[Health] = Health(e[Health].hp - 1, e[Health].max_hp)
Query Returns Iterator
world(Type1, Type2) returns a QueryResult that yields (entity, comp1, comp2) tuples. It's lazy—entities are fetched on iteration.
Updating Components¶
All Reads Return Copies¶
IMPORTANT: All component reads return deep copies, not references.
@system(reads=(Position, Velocity), writes=(Position,))
def movement(world: ScopedAccess) -> None:
for entity, pos, vel in world(Position, Velocity):
# pos and vel are COPIES - safe to mutate
pos.x += vel.dx
pos.y += vel.dy
world[entity, Position] = pos # Must write back!
# Or create new instance (same effect):
# world[entity, Position] = Position(pos.x + vel.dx, pos.y + vel.dy)
Must Write Back
Mutations to retrieved components do NOT persist automatically. You must write back via world[entity, Type] = component. This applies to all reads: world[entity, Type], world(Type) queries, and world.get(entity, Type).
Why Copies?
Returning copies prevents accidental mutation of world state, works with any storage backend (including remote/serialized), and maintains snapshot isolation guarantees.
Updating Multiple Components¶
Update multiple components on the same entity:
@system(reads=(Position, Velocity, Fuel), writes=(Position, Velocity, Fuel))
def movement_with_fuel(world: ScopedAccess) -> None:
for entity, pos, vel, fuel in world(Position, Velocity, Fuel):
if fuel.amount > 0:
# Update multiple components
world[entity, Position] = Position(pos.x + vel.dx, pos.y + vel.dy)
world[entity, Fuel] = Fuel(fuel.amount - 1)
else:
# Stop moving when out of fuel
world[entity, Velocity] = Velocity(0, 0)
Updating via Return Values¶
For pure functional systems, return changes instead of writing to world:
@system(reads=(Input,), writes=(Output,), mode=SystemMode.PURE)
def transform(world: ReadOnlyAccess) -> dict[EntityId, dict[type, Any]]:
"""Pure system - must return all changes."""
return {
entity: {Output: Output(inp.value * 2)}
for entity, inp in world.query(Input)
}
SystemMode.PURE
Pure systems receive ReadOnlyAccess and must return all changes. They cannot use world[entity, Type] = value.
Understanding Execution Behavior¶
Access Levels¶
AgentECS provides three access levels:
-
Standard Access
Type-level access. System can read A, B and write C. Runs in parallel with other systems.
-
Full Access (Optional Declarations)
Unrestricted access. Still runs in parallel with other systems.
-
Dev Mode
Unrestricted access AND runs in isolation. Use for debugging.
How Access Affects Execution:
graph LR
A["debug_system<br/>@system.dev<br/>(Group 1)"]
B["movement<br/>reads: Pos, Vel<br/>(Group 2)"]
C["damage<br/>writes: Health<br/>(Group 2)"]
D["render<br/>full access<br/>(Group 2)"]
A -->|runs first,<br/>then| B
B -.runs in parallel.- C
C -.runs in parallel.- D
style A fill:#ffcdd2
style B fill:#c8e6c9
style C fill:#c8e6c9
style D fill:#c8e6c9
Snapshot Isolation and Combination
All systems in an execution group see the same initial state (snapshot isolation). When multiple systems write to the same entity/component:
- Combinable values fold via
__combine__ - non-combinable values use last-writer-wins (registration order)
See Scheduling for details.
When to Use Access Declarations¶
Access declarations are optional in AgentECS. Under snapshot isolation with merge strategies, correctness doesn't require declarations. Here's when each approach is appropriate:
| Approach | Syntax | Use When |
|---|---|---|
| No declarations | @system() |
Quick prototyping, scripts, simple systems |
| With declarations | @system(reads=..., writes=...) |
Production code, team projects, complex systems |
| Dev mode | @system.dev() |
Debugging, inspecting state, tracing issues |
Benefits of Declaring Access:
- Runtime validation: Catch accidental access to undeclared components
- Documentation: Declarations show intent at a glance
- Future optimization: Declarations enable smarter scheduling (planned)
- Refactoring safety: Easier to understand what a system touches
When to Skip Declarations:
- Rapid prototyping where you're still figuring out what components you need
- One-off scripts or experiments
- Systems that legitimately need access to many/all components (aggregators, debuggers)
# Quick prototype - figure out what you need first
@system()
def experimental(world: ScopedAccess) -> None:
for e, pos in world(Position):
# Still exploring what else we need...
if Health in world.entity(e):
world[e, Health] = Health(100)
# Production - declare what you use
@system(reads=(Position, Health), writes=(Health,))
def production_ready(world: ScopedAccess) -> None:
for e, pos, health in world(Position, Health):
world[e, Health] = Health(100)
Gradual Adoption
Start with @system() to explore, then add declarations as your system stabilizes. This keeps iteration fast while enabling validation in production.
Frequency¶
Future Feature
frequency parameter exists but isn't yet used by scheduler:
@system(reads=(A,), writes=(B,), frequency=0.5)
def every_other_tick(world: ScopedAccess) -> None:
# Future: Run every 2 ticks
pass
Planned for frequency-based execution in a future release.
Conditions¶
Future Feature
Condition-based execution is planned but not yet implemented:
Dependencies¶
Future Feature
Explicit dependencies are planned but not yet implemented:
# Future API (not yet available)
@system(reads=(A,), writes=(B,), depends_on=[systemA, systemB])
def dependent_system(world: ScopedAccess) -> None:
# Runs after systemA and systemB
pass
Currently, use execution groups to control ordering. Dev mode systems run in separate groups before normal systems.
Typical Workflows¶
AgentECS is extremely flexible in how systems can be defined. Here are common patterns to get started:
Pattern-Match and execute per entity¶
Process each entity individually based on its components:
@system(reads=(Position, Velocity, Health), writes=(Position, Health))
def entity_update(world: ScopedAccess) -> None:
"""Update each entity independently."""
for entity, pos, vel, health in world(Position, Velocity, Health):
# Move entity
new_pos = Position(pos.x + vel.dx, pos.y + vel.dy)
# Damage if out of bounds
if new_pos.x < 0 or new_pos.x > 100:
world[entity, Health] = Health(health.hp - 10, health.max_hp)
world[entity, Position] = new_pos
When to Use
Use for logic that applies independently to each entity (movement, health decay, AI decisions).
Identify and operate on groups of entities¶
Collect entities, analyze as a group, then update:
@system(reads=(Position, AgentTag, Opinion), writes=(Opinion,))
def consensus_building(world: ScopedAccess) -> None:
"""Agents in proximity influence each other's opinions."""
agents = [(e, pos, opinion) for e, pos, opinion in world(Position, AgentTag, Opinion)]
for entity, pos, opinion in agents:
# Find nearby agents
nearby = [other_op for e, other_pos, other_op in agents
if e != entity and distance(pos, other_pos) < 10]
if nearby:
# Average with nearby opinions
avg_opinion = sum(op.value for op in nearby) / len(nearby)
blended = (opinion.value * 0.7 + avg_opinion * 0.3)
world[entity, Opinion] = Opinion(blended)
When to Use
Use for logic requiring awareness of multiple entities (consensus, flocking, collision detection).
Global coordination and world state¶
Systems can access singleton components for global parameters:
@system.dev() # Dev mode to access singleton
def temperature_adjustment(world: ScopedAccess) -> None:
"""Adjust global temperature based on agent consensus."""
from agentecs import SystemEntity
# Count agents with ConvergingTag
converging_count = sum(1 for _ in world(Position, ConvergingTag))
total_count = sum(1 for _ in world(Position, AgentTag))
if total_count > 0:
ratio = converging_count / total_count
# If majority converging, reduce temperature (decrease exploration)
config = world[SystemEntity.WORLD, GlobalConfig]
if ratio > 0.7:
world[SystemEntity.WORLD, GlobalConfig] = GlobalConfig(
temperature=max(0.1, config.temperature - 0.1)
)
When to Use
Use for systems that modify global parameters based on aggregate state (temperature, difficulty, resource availability).
Shared resources¶
Model resources shared across entities:
@component
@dataclass
class ResourceAccess:
requested: float
@system.dev()
def resource_allocation(world: ScopedAccess) -> None:
"""Allocate limited resources to competing agents."""
from agentecs import SystemEntity
# Get shared pool
pool = world[SystemEntity.WORLD, ResourcePool]
available = pool.amount
# Collect requests
requests = [(e, req.requested) for e, req in world(ResourceAccess)]
total_requested = sum(amt for _, amt in requests)
# Proportional allocation
for entity, requested in requests:
if total_requested > 0:
allocated = (requested / total_requested) * min(available, total_requested)
world[entity, Resources] = Resources(allocated)
When to Use
Use for competitive resource allocation (context windows, compute, database connections).
Exchange of resources between entities¶
Model bilateral or market-based exchanges:
@system(reads=(Credits, TradeOffer), writes=(Credits,))
def execute_trades(world: ScopedAccess) -> None:
"""Execute valid trade offers between entities."""
offers = [(e, offer) for e, offer in world(TradeOffer)]
for entity, offer in offers:
if world.has(entity, Credits) and world.has(offer.target, Credits):
buyer_credits = world[entity, Credits]
seller_credits = world[offer.target, Credits]
if buyer_credits.amount >= offer.price:
# Execute trade
world[entity, Credits] = Credits(buyer_credits.amount - offer.price)
world[offer.target, Credits] = Credits(seller_credits.amount + offer.price)
When to Use
Use for agent-to-agent transactions (trading, task delegation, information exchange).
Async Systems¶
Systems can be async for I/O-bound operations:
@system(reads=(Query,), writes=(Response,))
async def llm_inference(world: ScopedAccess) -> None:
"""Call LLM API for entities with queries."""
import asyncio
tasks = []
entities = []
for entity, query in world(Query):
task = call_llm_api(query.text) # Async function
tasks.append(task)
entities.append(entity)
# Parallel API calls
responses = await asyncio.gather(*tasks)
for entity, response in zip(entities, responses):
world[entity, Response] = Response(response)
Async Detection
AgentECS automatically detects async systems via inspect.iscoroutinefunction(). Mix sync and async systems freely—the framework handles both.
When to Use Async
Use async systems for:
- LLM API calls
- Database queries
- Network I/O
- Any operation that would block
The scheduler will await async systems and run them concurrently when possible.
System Best Practices¶
Small, Focused Systems
Prefer many small systems over few large ones. Small systems are easier to reason about and maintain.
Start Without Declarations
Start with @system() (no declarations) for quick prototyping. Add reads/writes when you want validation and documentation.
Declare What You Use
When declaring access, only list the component types you actually need. This provides runtime validation and documents intent.
Avoid Hidden State
Systems should not maintain state between ticks in global variables or class attributes. All state should live in components.
Use EntityHandle for Multiple Accesses
If accessing many components on the same entity, use world.entity(entity_id) to get a convenient handle.
Dev Mode for Debugging
Use @system.dev() when debugging. It runs in isolation (separate execution group), making it easier to reason about state changes without interference from other systems.
See Also¶
- Components: Define the data systems operate on
- Queries: Advanced query patterns with filters
- World Management: How systems interact with the world
- Scheduling: How systems are executed and parallelized