Custom Agent Development Guide#
This document explains how to add a custom agent to LoongSage. Agent extensions are likewise registry- and config-driven: inherit the base class, add a registration decorator, and reference the implementation through data_source.agent.name without changing scheduling code.
If one sample can be completed with a single model request—for example, ordinary question answering or a math problem—no agent is needed; leave agent.name empty. Add a custom agent only when the model must interact with tools repeatedly. Check the agent directory for reusable implementations first.
1. Development Steps#
The agent base class and built-in implementations live in the agent directory. Follow these steps to add an agent:
Inherit from BaseAgent and implement the async method
run_trajectory(trajectory). The constructor receivesrouter_url,reward_fn,sandbox_client,sandbox_id, andmax_response_len_per_trajectory; keep**kwargsfor custom fields from theagentconfig block. Sampling parameters are applied by Router and are not passed to agents.Read
prompt,label, andmetadatainrun_trajectory(). Send every model request torouter_url, then call the injectedreward_fnand return itsReward.reward_fnis synchronous, so call it throughasyncio.to_threadto keep the event loop free.If the agent uses a sandbox, use the injected
sandbox_clientandsandbox_iddirectly, and put both into the third argument (the context) you pass toreward_fnso the reward evaluates inside the same container.Register the implementation with
@register_agent("your-name")and place it in coda/custom/; LoongSage discovers it automatically, see Custom Extensions.
2. Minimal Example#
The following example shows a tool agent that uses a sandbox. Each agent defines its own tool protocol and decides how to parse tool calls out of a model reply; the framework ships no parser. This example reads the Chat Completions tool_calls field:
# coda/custom/my_agent.py
import asyncio
import json
from typing import Any
from coda.agentflow.agent import register_agent
from coda.agentflow.agent.base_agent import BaseAgent
from coda.reward.reward import Reward
@register_agent("my-agent")
class MyAgent(BaseAgent):
def __init__(self, *args: Any, max_turns: int = 5, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.max_turns = max_turns
async def run_trajectory(self, trajectory: dict[str, Any]) -> Reward:
label = trajectory.get("label")
metadata = trajectory.get("metadata") or {}
messages = list(trajectory["prompt"])
if self.sandbox_id is None:
self.sandbox_id = await asyncio.to_thread(self.sandbox_client.create, image=metadata["docker_image"])
for _ in range(self.max_turns):
response = await self.call_llm(messages)
assistant = response["choices"][0]["message"]
messages.append(assistant)
tool_calls = assistant.get("tool_calls") or []
if not tool_calls:
break
for tc in tool_calls:
# This example registers a single shell tool; dispatch on
# tc["function"]["name"] when there are several.
arguments = json.loads(tc["function"]["arguments"])
result = await asyncio.to_thread(
self.sandbox_client.execute, self.sandbox_id, arguments["command"])
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": f"{result['stdout']}{result['stderr']}",
})
return await asyncio.to_thread(
self.reward_fn,
messages,
label,
{**metadata, "sandbox_client": self.sandbox_client, "sandbox_id": self.sandbox_id},
)
With sandbox: {type: none} on the data source no sandbox is injected, tools run locally (asyncio.to_thread(your_tool, ...)), and metadata is passed as the reward context. See gsm8k_agent.py (calculator tool) and bcp_agent.py (HTTP tools) for that shape.
3. Config Enablement#
Once registered, select the agent by name in the data source. Other fields in the agent block are passed directly to the constructor, while reward and sandbox are configured independently:
data_source:
agent:
name: my-agent # ← the @register_agent registered name
max_turns: 5 # maximum number of model requests
context_length: 65536 # context window available while the agent completes one task
reward:
name: exact-match # ← injected as reward_fn
max_response_len_per_trajectory: 32768 # cumulative token budget for replies and tool results
sandbox: {type: remote} # ← AgentFlow injects client + id; none disables it
agent.context_length configures the context window available while the agent completes one task. The Router keeps the current context plus the next generation within this limit, and agents that support compaction can use it to manage their context. Omit it or set it to 0 to disable the additional limit. This differs from max_response_len_per_trajectory, which controls the cumulative token budget for replies and tool results.
After configuration, LoongSage creates one agent instance for each trajectory that uses an agent. Each data_sources[i] may select its own agent, reward, and sandbox backend.