Customizing the OTA Cycle¶
Documentation update in progress
This Amphibious tutorial is currently outdated and may not match the bridgic-amphibious 0.2.0 API. We are actively updating it. Please use it as conceptual background only until the update is complete.
Every amphibious think unit runs one Observe-Think-Act (OTA) round, recorded as a single OTARecord on ota_context.ota_record. This tutorial shows how to customize each phase by overriding the cycle's hooks.
The round flows through three phases, and a hook fires at each:
- Observe —
observationsets the round's perception (ota_context.obs_result). - Think —
thinking(theCognitiveWorker's core) produces a decision (ota_context.think_result). Covered in depth in the CognitiveWorker tutorial; recapped briefly here. - Act —
before_actionmay rewrite the decision,action_tool_callexecutes its tool calls intoota_context.action_result, andafter_actionreacts to the result.
Two hook levels, and _DELEGATE chaining¶
Hooks live at two levels:
- Worker-level — methods on a
CognitiveWorkersubclass. They run first. - Agent-level — methods on the
AmphibiousAutomasubclass. Shared across every worker.
A worker-level hook that returns _DELEGATE (or None) chains to the matching agent-level hook. Return any other value to handle it locally and stop the chain.
Every hook is payload-free: it takes (self, ota_context, context=None) and reads the round's state off ota_context (obs_result / think_result / action_result) — never from arguments.
| Level | Accepted forms |
|---|---|
Worker (CognitiveWorker) | coroutine (return value / return _DELEGATE) or async generator (yield ActionCall(...), then optionally yield RETURN(...)) |
Agent (AmphibiousAutoma) | async generator (use if False: yield when the body has no real yield) |
Initialize¶
Set up the LLM. The examples read credentials from environment variables, so set MODEL_NAME, API_KEY, and BASE_URL before running.
import os
model_name = os.environ.get("MODEL_NAME")
api_key = os.environ.get("API_KEY")
api_base = os.environ.get("BASE_URL")
from bridgic.llms.openai import OpenAILlm, OpenAIConfiguration
llm = OpenAILlm(
api_key=api_key,
api_base=api_base,
timeout=30,
configuration=OpenAIConfiguration(
model=model_name,
temperature=0.0,
max_tokens=16384,
),
)
A tool, an OTAContext, and a Context¶
Tools are declared on the OTA context — nothing is auto-injected. We declare one domain tool plus the built-in bash tool (used later by a live-snapshot observation), and a bare big-loop Context.
from bridgic.amphibious import OTAContext, Context, bash_tool
async def deploy_service(service: str) -> str:
"""Deploy a named service to production."""
return f"deployed {service}"
async def delete_service(service: str) -> str:
"""Permanently delete a named service."""
return f"deleted {service}"
# Small-loop context: declare the tools this run carries.
class OpsOTAContext(OTAContext):
pass
OpsOTAContext.tool(deploy_service)
OpsOTAContext.tool(delete_service)
OpsOTAContext.tool(bash_tool)
# Big-loop context: free-form knowledge (bare here).
class OpsContext(Context):
pass
We'll also reuse a small think unit across the examples. Its thinking() is the standard tool-selecting worker — assemble the prompt from ota_context.summary(), call self._llm.aselect_tool, and return its natural result. (Full details live in the CognitiveWorker tutorial.)
from bridgic.amphibious import CognitiveWorker
from bridgic.core.model.types import Message, Role
class OpsThink(CognitiveWorker):
async def thinking(self, ota_context, context=None):
messages = [Message.from_text(ota_context.summary(), role=Role.USER)]
# aselect_tool returns (tool_calls, content); a reply with no tool
# calls is the finish.
return await self._llm.aselect_tool(
messages=messages,
tools=[t.to_tool() for t in ota_context.tools],
)
observation — inject custom perception¶
observation(self, ota_context, context=None) runs first in the round and sets ota_context.obs_result — the perception the next thinking() reads.
Worker form (coroutine). Return a value to set this round's obs_result, or _DELEGATE / None to chain to the agent-level observation.
from bridgic.amphibious import _DELEGATE
class WorkerObsThink(OpsThink):
async def observation(self, ota_context, context=None):
# A returned value becomes this round's obs_result.
if not ota_context.ota_record:
return "Tip: deploy to staging before production."
# Later rounds: chain to the agent-level observation.
return _DELEGATE
Agent form (async generator). Yield RETURN(text) to set obs_result. Exhausting the generator without RETURN preserves the previous value.
from bridgic.amphibious import (
AmphibiousAutoma, think_unit, ThinkUnit, RETURN,
)
class ObservingAgent(AmphibiousAutoma[OpsOTAContext, OpsContext]):
worker = think_unit(WorkerObsThink(), max_attempts=5)
# Agent-level observation: shared across all workers. The worker's
# observation chains here when it returns _DELEGATE.
async def observation(self, ota_context, context=None):
yield RETURN("System: prod cluster, change window OPEN.")
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
Generator form with a live snapshot. When perception needs a fresh tool call each round, yield ActionCall(...) for the raw result, then yield RETURN(...) to set obs_result. The yield ActionCall(...) is a raw tool execution — it does not re-enter the hook chain.
from bridgic.amphibious import ActionCall
class SnapshotAgent(AmphibiousAutoma[OpsOTAContext, OpsContext]):
worker = think_unit(OpsThink(), max_attempts=5)
async def observation(self, ota_context, context=None):
# Live snapshot before each think: yield a raw tool call, then RETURN it.
snapshot = yield ActionCall("bash", command="date -u")
yield RETURN(snapshot[0].result if snapshot else None)
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
before_action — override the decision¶
before_action(self, ota_context, context=None) runs after thinking() and before the act phase. The pending decision is on ota_context.think_result — a ThinkResult with:
.step_content— the think text..tool_calls— a list ofStepToolCall, each with.tool(the tool name) and.tool_arguments(a list ofToolArgument).
Use it to filter or rewrite the decision before any tool runs. Below we drop calls to blocked tools.
Agent form. yield RETURN(modified_decision) to override; exhausting without RETURN is passthrough (the decision stands).
class SafeAgent(AmphibiousAutoma[OpsOTAContext, OpsContext]):
worker = think_unit(OpsThink(), max_attempts=5)
BLOCKED = {"delete_service"}
async def before_action(self, ota_context, context=None):
# Payload-free: read the pending decision off the current round.
decision = ota_context.think_result
decision.tool_calls = [
c for c in decision.tool_calls if c.tool not in self.BLOCKED
]
yield RETURN(decision) # override before the act phase runs
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
Worker form. A worker-level before_action is a coroutine: return the modified decision to handle it locally, or _DELEGATE to chain to the agent-level hook.
class FilteringThink(OpsThink):
BLOCKED = {"delete_service"}
async def before_action(self, ota_context, context=None):
decision = ota_context.think_result
if any(c.tool in self.BLOCKED for c in decision.tool_calls):
decision.tool_calls = [
c for c in decision.tool_calls if c.tool not in self.BLOCKED
]
return decision # handled locally; stop the chain
return _DELEGATE # nothing to filter; chain to the agent
after_action — react to the result¶
after_action(self, ota_context, context=None) runs after the act phase. Read the result from ota_context.action_result — an ActionResult with .results: List[ActionStepResult], each carrying .tool_name / .tool_arguments / .tool_result / .success / .error.
To record custom per-round bookkeeping, fold a field onto the current round via ota_context._current_record().<field> = .... OTARecord is extra="allow", so this is the framework's one sanctioned in-place seam. RETURN is unused in after_action.
from bridgic.amphibious import ActionResult
class TrackingAgent(AmphibiousAutoma[OpsOTAContext, OpsContext]):
worker = think_unit(OpsThink(), max_attempts=5)
async def after_action(self, ota_context, context=None):
action_result = ota_context.action_result
if isinstance(action_result, ActionResult):
succeeded = sum(1 for r in action_result.results if r.success)
failed = sum(1 for r in action_result.results if not r.success)
# Fold custom fields onto the current round (OTARecord is extra="allow").
ota_context._current_record().succeeded = succeeded
ota_context._current_record().failed = failed
if False: # keep this an async generator even when nothing yields
yield
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
action_tool_call — customize execution (advanced)¶
action_tool_call(self, ota_context, context=None) -> ActionResult is a coroutine (not an async generator). It reads the decision off ota_context.think_result (already filtered by any before_action), runs its tool calls, and returns an ActionResult. The default executes the calls concurrently.
Override it to change how the decision's tool calls execute — sequential, rate-limited, or sandboxed. The simplest customization delegates to the default and only adjusts around it; here we run a marker before falling back to super().
class SequentialAgent(AmphibiousAutoma[OpsOTAContext, OpsContext]):
worker = think_unit(OpsThink(), max_attempts=5)
async def action_tool_call(self, ota_context, context=None) -> ActionResult:
# A coroutine returning ActionResult. Add custom execution policy here
# (e.g. a rate-limit gate), then delegate to the default executor.
decision = ota_context.think_result
if decision is not None and len(decision.tool_calls) > 3:
# Example policy hook: cap fan-out before executing.
decision.tool_calls = decision.tool_calls[:3]
return await super().action_tool_call(ota_context, context)
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
Hook summary¶
| Hook | When it fires | Signature | Typical use | Form |
|---|---|---|---|---|
observation | Start of the round, before thinking | (self, ota_context, context=None) | Inject perception; set obs_result | Worker: coroutine or generator. Agent: generator; yield RETURN(text) |
thinking | The Think phase (worker core) | (self, ota_context, context=None) | Call the LLM; return its natural result | Worker coroutine (required override) |
before_action | After Think, before Act | (self, ota_context, context=None) | Filter / rewrite the decision (think_result) | Worker: coroutine or generator. Agent: generator; yield RETURN(decision) |
action_tool_call | The Act phase | (self, ota_context, context=None) -> ActionResult | Customize how tool calls execute | Coroutine (NOT a generator) |
after_action | After Act | (self, ota_context, context=None) | React to action_result; fold per-round fields | Worker: coroutine or generator. Agent: generator (RETURN unused) |
Worker-level hooks return _DELEGATE / None to chain to the matching agent-level hook.
Next Steps¶
- CognitiveWorker & think_unit — the
thinking()method and the think unit in depth. - Custom Context — modeling the two-loop state your hooks read and fold onto.
- Built-in Tools — declare the shipped tools your hooks call.