Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/autoresearch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Apollo autoresearch

`apollo autoresearch` runs bounded experiments against a numeric local metric.
It measures a baseline, asks the agent for one hypothesis at a time, validates
the candidate, keeps only improvements, and records every decision in a TOML
ledger.

Create `.apollo/autoresearch.toml` in the workspace:

```toml
objective = "Reduce warm startup latency"
metric_command = "cargo bench --bench startup -- --output-format json | jq -r .median_ms"
direction = "minimize"
validation_command = "cargo test --workspace --all-features"
validation_retries = 2
command_timeout_secs = 300
samples = 3
max_iterations = 10
max_duration_secs = 1800
ledger_path = ".apollo/autoresearch-ledger.toml"
```

Run it with:

```bash
apollo autoresearch --workspace .
apollo autoresearch --workspace . --resume
```

The metric command must print at least one finite numeric value. Commands in
the specification are trusted local code and run through `sh -c`; do not use a
specification copied from an untrusted source. The workspace must be clean at
startup. Rejected iterations are restored to their checkpoint; ignored
configuration/state files are restored to their pre-iteration contents, and
untracked files created by that iteration are removed. Build output under
`target/` is treated as disposable process state. Run autoresearch in a
dedicated worktree when experimenting with valuable local files.

If `ledger_path` is inside the workspace, it must be Git-ignored; an external
ledger path is also supported. This keeps the durable ledger from becoming an
unrelated dirty change after an accepted iteration.

Accepted iterations are committed locally as `autoresearch: iteration N`.
Pushing is intentionally not automatic.

The ledger records the branch, the accepted commit, a fingerprint of the
metric/validation definition, and a unique run chat id. `--resume` refuses to
continue if the branch, HEAD, or experiment definition no longer matches the
ledger.

The autoresearch runner exposes only the runtime and filesystem tool groups to
the experiment agent. Network, messaging, memory, MCP, dynamic tools, host
plugins, and workspace skills are not ambient capabilities for this workflow;
history, personal-context injection, ZKR recall/capture, and reflection are
also disabled.

Validation and metric processes are bounded by `command_timeout_secs`, and a
validation command may be retried with `validation_retries`. The whole run is
bounded by `max_iterations` and `max_duration_secs`.

Apollo also records estimated system, history, and tool-definition context in
aggregate counters in the existing cost tracker. The estimates use four
characters per token and are useful for comparing harness configurations, not
for billing.

## Design notes

The loop follows two useful ideas from adjacent agent systems: bounded
autonomous runs with explicit quality gates and budgets (as in
[Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent)), and
capability-oriented access instead of ambient tools (as in
[Cloudflare OS](https://github.com/cloudflare/cloudflare-os)). Apollo keeps the
implementation local and Git-backed: there is no hosted worker or remote
control plane in this workflow.
68 changes: 45 additions & 23 deletions src/agent/loop_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub struct AgentRunner {
plugin_registry: Arc<RwLock<PluginRegistry>>,
/// Current trajectory being recorded (per chat)
trajectories: Arc<RwLock<HashMap<String, Trajectory>>>,
/// Whether this runner may read or persist conversational memory.
memory_enabled: bool,
memory_ideas: crate::config::MemoryIdeasConfig,
group_chat: crate::config::GroupChatConfig,
#[cfg(feature = "zkr-memory")]
Expand Down Expand Up @@ -89,6 +91,7 @@ impl AgentRunner {
hook_manager: Arc::new(HookManager::new()),
plugin_registry: Arc::new(RwLock::new(PluginRegistry::new())),
trajectories: Arc::new(RwLock::new(HashMap::new())),
memory_enabled: true,
memory_ideas: crate::config::MemoryIdeasConfig::default(),
group_chat: crate::config::GroupChatConfig::default(),
#[cfg(feature = "zkr-memory")]
Expand Down Expand Up @@ -180,6 +183,15 @@ impl AgentRunner {
self
}

/// Enable or disable conversational memory for this runner.
///
/// Restricted automation uses this to keep prior conversations and
/// experiment output out of the model context and persistent stores.
pub fn with_memory_enabled(mut self, enabled: bool) -> Self {
self.memory_enabled = enabled;
self
}

pub fn with_group_chat(mut self, cfg: crate::config::GroupChatConfig) -> Self {
self.group_chat = cfg;
self
Expand Down Expand Up @@ -589,7 +601,7 @@ impl AgentRunner {

let base_prompt = self.system_prompt.read().await.clone();
#[cfg(feature = "zkr-memory")]
let system_prompt = if self.zkr_config.self_improve {
let system_prompt = if self.memory_enabled && self.zkr_config.self_improve {
if let Some(store) = &self.zkr {
match store.augment_prompt(&effective_text, &base_prompt).await {
Ok(augmented) => augmented,
Expand Down Expand Up @@ -644,7 +656,7 @@ impl AgentRunner {
}
}
}
if msg.is_group {
if self.memory_enabled && msg.is_group {
if let Some(group_memory) = self.load_group_memory(&msg.chat_id).await? {
if !group_memory.trim().is_empty() {
messages.push(ChatMessage::system(crate::context::group_memory_prompt(
Expand All @@ -655,23 +667,25 @@ impl AgentRunner {
}
}

let history = crate::memory::context_inject::merged_history(
&self.memory,
&msg.chat_id,
self.memory_ideas.principal_id.as_deref(),
self.agent_config.max_history_messages,
)
.await?;
for (role, content) in history {
match role.as_str() {
"user" => messages.push(ChatMessage::user(&content)),
"assistant" => messages.push(ChatMessage::assistant(&content)),
_ => {}
if self.memory_enabled {
let history = crate::memory::context_inject::merged_history(
&self.memory,
&msg.chat_id,
self.memory_ideas.principal_id.as_deref(),
self.agent_config.max_history_messages,
)
.await?;
for (role, content) in history {
match role.as_str() {
"user" => messages.push(ChatMessage::user(&content)),
"assistant" => messages.push(ChatMessage::assistant(&content)),
_ => {}
}
}
}

let mut user_turn = effective_text.clone();
if self.memory_ideas.inject_context {
if self.memory_enabled && self.memory_ideas.inject_context {
let blocks = crate::memory::context_inject::personal_context_blocks(
&self.memory,
crate::memory::context_inject::InjectConfig {
Expand All @@ -687,7 +701,7 @@ impl AgentRunner {
}
}
#[cfg(feature = "zkr-memory")]
if self.zkr_config.inject_recall {
if self.memory_enabled && self.zkr_config.inject_recall {
if let Some(store) = &self.zkr {
match store
.context(&effective_text, self.zkr_config.recall_limit)
Expand Down Expand Up @@ -757,6 +771,7 @@ impl AgentRunner {
workspace: self.workspace.clone(),
max_tool_iterations: self.agent_config.max_rounds,
auto_compact_after: self.agent_config.auto_compact_after,
cost_tracker: Some(Arc::clone(&self.cost_tracker)),
// Both engines must run the same hooks and emit the same events.
hook_ctx: crate::agent::rotary_bridge::ToolHookContext::new(
self.hooks.read().unwrap().clone(),
Expand Down Expand Up @@ -810,7 +825,9 @@ impl AgentRunner {
text: &str,
delivery: &Delivery<'_>,
) -> anyhow::Result<String> {
self.persist_conversation(msg, text).await?;
if self.memory_enabled {
self.persist_conversation(msg, text).await?;
}

// Mark trajectory as successful, record final response
{
Expand All @@ -829,11 +846,16 @@ impl AgentRunner {
text.to_string(),
))
.await;
if let Some(ws) = &self.session_note_workspace {
let preview: String = text.chars().take(200).collect();
if !preview.is_empty() {
let _ =
crate::memory::session_note::append_session_note(ws, &msg.chat_id, &preview);
if self.memory_enabled {
if let Some(ws) = &self.session_note_workspace {
let preview: String = text.chars().take(200).collect();
if !preview.is_empty() {
let _ = crate::memory::session_note::append_session_note(
ws,
&msg.chat_id,
&preview,
);
}
}
}

Expand All @@ -851,7 +873,7 @@ impl AgentRunner {
let delivered = delivery.deliver(&msg.chat_id, text).await?;

#[cfg(feature = "zkr-memory")]
if self.zkr_config.self_improve {
if self.memory_enabled && self.zkr_config.self_improve {
if let Some(store) = &self.zkr {
let _ = store
.record_reflection(&msg.text, "agent turn", text, "completed")
Expand Down
56 changes: 54 additions & 2 deletions src/agent/rotary_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use rx4::provider::{

use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook};
use crate::agent::stream::{emit, AgentStreamEvent, AgentStreamTx};
use crate::cost::{ContextSnapshot, CostTracker, TokenUsage};
use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
use crate::providers::{ChatMessage, ChatRequest, Provider as UnthinkclawProvider};
use crate::tools::{Tool as UnthinkclawTool, ToolResult as UnthinkclawToolResult, ToolSpec};
Expand Down Expand Up @@ -214,16 +215,21 @@ pub struct RotaryProviderAdapter {
inner: Arc<dyn UnthinkclawProvider>,
id: String,
name: String,
cost_tracker: Option<Arc<CostTracker>>,
}

impl RotaryProviderAdapter {
pub fn new(provider: Arc<dyn UnthinkclawProvider>) -> Self {
pub fn new(
provider: Arc<dyn UnthinkclawProvider>,
cost_tracker: Option<Arc<CostTracker>>,
) -> Self {
let id = provider.name().to_string();
let name = format!("apollo-{}", provider.name());
Self {
inner: provider,
id,
name,
cost_tracker,
}
}
}
Expand Down Expand Up @@ -300,12 +306,53 @@ impl Rx4Provider for RotaryProviderAdapter {
max_tokens: Some(8192),
};

if let Some(tracker) = &self.cost_tracker {
let system_chars = system
.as_ref()
.map(|value| value.chars().count())
.unwrap_or(0);
let history_chars = messages
.iter()
.map(|message| message.content.chars().count())
.sum::<usize>();
let tool_chars = tools
.iter()
.map(|tool| {
serde_json::to_string(tool)
.unwrap_or_default()
.chars()
.count()
})
.sum::<usize>();
tracker
.record_context(ContextSnapshot {
system_chars,
history_chars,
tool_chars,
estimated_input_tokens: (system_chars + history_chars + tool_chars).div_ceil(4),
})
.await;
}

let response = self
.inner
.chat(&request)
.await
.map_err(|e| Rx4ProviderError::Api(e.to_string()))?;

if let (Some(tracker), Some(usage)) = (&self.cost_tracker, response.usage.as_ref()) {
let _ = tracker
.record(
model,
TokenUsage {
input_tokens: usage.input_tokens as usize,
output_tokens: usage.output_tokens as usize,
total_tokens: usage.input_tokens as usize + usage.output_tokens as usize,
},
)
.await;
Comment on lines +343 to +353

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not price unknown models as free

When the configured model is absent from CostTracker's hard-coded price table—as the default gpt-5.5 currently is—this newly wired usage recording calls record, whose unknown-model fallback assigns zero input and output prices. /cost and the HTTP summary consequently report paid default-model calls as costing $0; represent unknown pricing explicitly or ensure every selectable default and alias resolves to a real price rather than silently recording free usage.

Useful? React with 👍 / 👎.

}

// Build a stream that emits the response as events
let text = response.text.unwrap_or_default();
let tool_calls = response.tool_calls;
Expand Down Expand Up @@ -402,6 +449,8 @@ pub struct RotaryBridgeConfig {
/// rx4 auto-compaction threshold. `0` leaves compaction off; a non-zero
/// value is forwarded to `Agent::auto_compact_after`.
pub auto_compact_after: usize,
/// Optional tracker used for provider usage and context-shape telemetry.
pub cost_tracker: Option<Arc<CostTracker>>,
/// Pre/post tool hooks, so rx4 enforces the same permissions as the
/// legacy loop.
pub hook_ctx: ToolHookContext,
Expand All @@ -428,7 +477,10 @@ pub struct RotaryAgentBridge {
impl RotaryAgentBridge {
/// Build a new bridge from the given configuration.
pub fn new(config: RotaryBridgeConfig) -> Self {
let rx4_provider = Arc::new(RotaryProviderAdapter::new(config.provider));
let rx4_provider = Arc::new(RotaryProviderAdapter::new(
config.provider,
config.cost_tracker,
));

let mut agent = rx4::Agent::new();
agent.set_model(&config.model);
Expand Down
Loading
Loading