Built-in Tools¶
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.
The framework ships seven ready-made tool specs — a shell, a filesystem toolkit, file search, and a human-in-the-loop prompt. They cover the capabilities most agents reach for, so you rarely have to hand-roll them.
Nothing is auto-injected. A run carries a built-in only if its OTAContext declares it via OTAContext.tool. Whatever you declare is exactly what the run's tools field holds — and restricting capability is just declaring a smaller subset. The same tool works in both modes: the LLM calls it inside a ThinkUnit (agent mode), or you yield ActionCall("name", ...) (workflow mode).
The Seven Shipped Tools¶
Each tool is a FunctionToolSpec exported from bridgic.amphibious as a *_tool constant. The LLM-facing tool name is snake_case (bash, read_file, ...). All filesystem paths must be absolute.
| Tool spec | Tool name | What it does | Key params |
|---|---|---|---|
request_human_tool | request_human | Pause and ask the human operator a question (HITL) | prompt, channel=None |
bash_tool | bash | Run a shell command; returns stdout verbatim, non-zero exit raises | command, timeout=120000, cwd="" |
read_file_tool | read_file | Read a file in cat -n format; required before modifying it | file_path, offset=0, limit=0 |
write_file_tool | write_file | Create a new file, or overwrite an existing one | file_path, content |
edit_file_tool | edit_file | Exact-string replacement with a uniqueness check | file_path, old_string, new_string, replace_all=False |
glob_tool | glob | Find files by glob pattern, sorted by mtime | pattern, path="" |
grep_tool | grep | Regex content search across files | pattern, path="", glob="", output_mode="files_with_matches", case_insensitive=False, head_limit=0 |
write_file (overwrite) and edit_file enforce a read-before-modify invariant — covered below.
Initialize¶
The agent-mode examples need an LLM. The examples read credentials from environment variables, so set MODEL_NAME, API_KEY, and BASE_URL before running. The workflow-mode examples run without one.
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 Sandbox to Explore¶
So the filesystem examples have something concrete to act on, we create a small temporary project directory. Every path we pass to a built-in below is absolute.
import os
import tempfile
SANDBOX = tempfile.mkdtemp(prefix="amphi_builtin_")
os.makedirs(os.path.join(SANDBOX, "src"), exist_ok=True)
with open(os.path.join(SANDBOX, "src", "app.py"), "w") as f:
f.write("def greet(name):\n return f\"Hello, {name}!\"\n")
with open(os.path.join(SANDBOX, "src", "utils.py"), "w") as f:
f.write("TIMEOUT = 30 # seconds\n")
with open(os.path.join(SANDBOX, "README.md"), "w") as f:
f.write("# Demo project\nA tiny project to explore the built-in tools.\n")
print("Sandbox:", SANDBOX)
Declaring Tools on an OTA Context¶
There are three ways to declare a tool, and they compose freely. OTAContext.tool works as both a call and a decorator.
- Declare the whole set at once with
ALL_BUILTIN_TOOLS. - Declare individual specs by passing each
*_toolconstant. - Decorate your own async function with
@MyOTAContext.tool.
Below we build a context that carries every built-in plus a custom tool.
from bridgic.amphibious import (
OTAContext, Context,
bash_tool, read_file_tool, write_file_tool, edit_file_tool,
glob_tool, grep_tool, request_human_tool,
)
from bridgic.amphibious.builtin_tools import ALL_BUILTIN_TOOLS
class CodeOTAContext(OTAContext):
pass
# Form 1: declare the whole built-in set at once.
for _t in ALL_BUILTIN_TOOLS:
CodeOTAContext.tool(_t)
# Form 3: decorate your own async function — it joins the same `tools` field.
@CodeOTAContext.tool
async def line_count(file_path: str) -> str:
"""Count the lines in a file (absolute path)."""
with open(file_path) as fh:
return str(sum(1 for _ in fh))
# Big-loop knowledge context (bare here — nothing extra to render).
class CodeContext(Context):
pass
# Sanity check: the declared tool names this context carries.
print([t.tool_name for t in CodeOTAContext().tools])
If you wanted only a specific few built-ins instead of the whole set, you would declare them individually — exactly the same call, one spec at a time:
for _t in (bash_tool, read_file_tool, glob_tool, grep_tool):
CodeOTAContext.tool(_t)
Declaring a smaller subset is the only mechanism for restricting capability — there is no builtin_tools class attribute and no arun(builtin_tools=...) kwarg. We use that property deliberately in the read-only section below.
Agent Mode — the LLM investigates¶
In agent mode the LLM autonomously chooses which tools to call. Here it explores the sandbox: it can glob for files, grep for a symbol, and read_file to inspect them — entirely on its own. We just declare a CognitiveWorker whose thinking() assembles a prompt and offers the context's tools, then drive it from on_agent with ThinkUnit.
from bridgic.amphibious import AmphibiousAutoma, CognitiveWorker, think_unit, ThinkUnit
from bridgic.core.model.types import Message, Role
class CodeThink(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); the framework adapts it
# into a decision. A reply with no tool calls ends the loop.
return await self._llm.aselect_tool(
messages=messages,
tools=[t.to_tool() for t in ota_context.tools],
)
class CodeInvestigator(AmphibiousAutoma[CodeOTAContext, CodeContext]):
worker = think_unit(CodeThink(), max_attempts=15)
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
# Only on_agent is overridden, so AUTO resolves to AGENT mode.
investigator = CodeInvestigator(verbose=True)
answer = await investigator.arun(
llm=llm,
user_input=(
f"Explore the project at {SANDBOX}. Use glob to list the Python files, "
"grep for the function named 'greet', read the file that defines it, "
"and summarize what the project does."
),
)
print(answer)
Workflow Mode — you spell out the steps¶
In workflow mode you call the same built-ins deterministically with yield ActionCall("name", ...), which hands back a List[ToolResult]. A pure workflow needs no LLM. The sequence below demonstrates the read-before-modify ordering explicitly: glob to discover, then read_file before the edit_file that mutates the same path.
from bridgic.amphibious import ActionCall, RETURN
class ConfigPatcher(AmphibiousAutoma[CodeOTAContext, CodeContext]):
async def on_workflow(self, ota_context, context=None):
# 1. Discover the Python files (sorted by mtime).
found = yield ActionCall("glob", pattern="**/*.py", path=SANDBOX)
listing = found[0].result if found else ""
utils_path = os.path.join(SANDBOX, "src", "utils.py")
# 2. Read BEFORE modifying — this records the file's mtime so the
# edit is allowed.
yield ActionCall("read_file", file_path=utils_path)
# 3. Now the edit on the same path is permitted.
edited = yield ActionCall(
"edit_file",
file_path=utils_path,
old_string="TIMEOUT = 30",
new_string="TIMEOUT = 60",
)
# 4. Confirm by reading it back.
after = yield ActionCall("read_file", file_path=utils_path)
yield RETURN(
"Globbed files:\n" + listing
+ "\n\nedit_file result: " + str(edited[0].result if edited else "N/A")
+ "\n\nFile now reads:\n" + str(after[0].result if after else "N/A")
)
patcher = ConfigPatcher(verbose=True)
result = await patcher.arun(user_input="Bump the timeout") # no llm needed
print(result)
Read-before-Modify Safety¶
write_file (when overwriting an existing file) and edit_file refuse to act on a path that has not been read with read_file in the current arun(), and also refuse if the file changed externally between the read and the modify. This guards against blind edits.
The tracker (AmphibiousAutoma._read_tracker, an absolute-path → mtime map) is reset at every arun() entry, so the invariant is scoped to a single run.
Below, the workflow edits without reading first. In a pure workflow a failed ActionCall propagates as a RuntimeError, which we catch to show the guard firing.
class BlindEditor(AmphibiousAutoma[CodeOTAContext, CodeContext]):
async def on_workflow(self, ota_context, context=None):
app_path = os.path.join(SANDBOX, "src", "app.py")
# No read_file on app_path first -> the read-before-modify guard trips.
result = yield ActionCall(
"edit_file",
file_path=app_path,
old_string="Hello",
new_string="Hi",
)
yield RETURN(str(result[0].result if result else "N/A"))
try:
await BlindEditor().arun(user_input="Edit without reading")
print("No error raised (unexpected)")
except RuntimeError as exc:
print("Guard fired as expected:")
print(exc)
Restricting Capability — a Read-only Context¶
Because nothing is auto-injected, you restrict capability simply by declaring a smaller subset. A read-only investigator declares only the non-mutating built-ins — read_file, glob, grep, and request_human for HITL — so bash, write_file, and edit_file are unavailable to the run. There is no toolset to subtract from; you just never declare them.
class ReadOnlyOTAContext(OTAContext):
pass
# Only safe, non-mutating built-ins. bash / write_file / edit_file are absent.
for _t in (read_file_tool, glob_tool, grep_tool, request_human_tool):
ReadOnlyOTAContext.tool(_t)
print("Read-only context carries:", [t.tool_name for t in ReadOnlyOTAContext().tools])
class ReadOnlyAuditor(AmphibiousAutoma[ReadOnlyOTAContext, CodeContext]):
worker = think_unit(CodeThink(), max_attempts=10)
async def on_agent(self, ota_context, context=None):
yield ThinkUnit("worker")
auditor = ReadOnlyAuditor(verbose=True)
audit = await auditor.arun(
llm=llm,
user_input=(
f"Audit the project at {SANDBOX}: use grep to find any TODO or timeout "
"settings and report them. Do not modify anything."
),
)
print(audit)
Human-in-the-Loop: request_human¶
request_human_tool is just another built-in — declare it on the OTA context and the LLM can autonomously call request_human(prompt, channel=None) from inside any ThinkUnit to pause and ask the operator. With no @human_channel handler registered it falls back to stdin; register one to route the prompt to your own UI.
class AskingOTAContext(OTAContext):
pass
AskingOTAContext.tool(read_file_tool)
AskingOTAContext.tool(request_human_tool) # the LLM can now ask the human
The deterministic counterpart is yield HumanCall(prompt=...) in on_workflow. Both entry points share the same @human_channel registry — see the linked tutorials below.
Next Steps¶
- Dual-Mode Orchestration —
on_agentvson_workflow, and therequest_human/HumanCallHITL paths. - Custom OTA Context — declaring tools and shaping the small-loop context.
- Quick Start — the five-minute introduction to both modes.