Skip to content
Merged
21 changes: 20 additions & 1 deletion docs/native-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,13 @@ tolerated for forward-compatibility.
| `OPENAB_AGENT_MODEL` | — (required for Anthropic) | Anthropic model id, optionally `provider/`-qualified (e.g. `claude-opus-4-8`, `anthropic/claude-opus-4-8`). No hardcoded default — dateless 4.6+ IDs are fixed canonical IDs that retire each generation, so the agent fails loud if unset rather than pin a model that will eventually 404. Overrides `model` in [config.json](#configuration-file-configjson). |
| `OPENAB_AGENT_OPENAI_MODEL` | `gpt-5.4-mini` | Model to use (must be supported by your ChatGPT plan — see [Supported Models](#supported-models-chatgpt-subscription)) |
| `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL |
| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) |
| `OPENAB_AGENT_XAI_MODEL` | `grok-4.5` | xAI model to use (see [xAI credentials](#xai-credentials-supergrok--x-premium)) |
| `OPENAB_AGENT_XAI_BASE_URL` | `https://api.x.ai/v1` | xAI API base URL |
| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`, `xai`, `grok`) |
| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens. Overrides `max_tokens` in config.json. |
| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom Codex OAuth client ID |
| `OPENAB_AGENT_ANTHROPIC_CLIENT_ID` | Claude Code's client | Custom Anthropic OAuth client ID |
| `OPENAB_AGENT_XAI_CLIENT_ID` | grok CLI's client | Custom xAI OAuth client ID |
| `OPENAB_AGENT_MAX_TOOL_LOOPS` | `50` | Max tool-call iterations per prompt before the agent gives up |
| `ANTHROPIC_API_KEY` | — | Anthropic API key. Highest-precedence Anthropic credential (see [Anthropic credentials](#anthropic-credentials)). |
| `CLAUDE_CODE_OAUTH_TOKEN` | — | Pre-provisioned long-lived Claude Pro/Max subscription token (from `claude setup-token`). Fleet route — no interactive login, no `auth.json` write. |
Expand Down Expand Up @@ -114,6 +117,22 @@ Three ways to authenticate Anthropic, resolved in this **precedence** (ADR §5.3
A higher-precedence source's own errors (e.g. a key set but no model) surface
rather than silently falling through to a lower one.

### xAI credentials (SuperGrok / X Premium)

Sign in with a SuperGrok or X Premium subscription via the RFC 8628 device-code
flow — no `XAI_API_KEY` to provision, and headless-friendly (approve on any
device; ideal for pods via `kubectl exec`):

```bash
openab-agent auth xai-device
```

Prints a user code and an `https://auth.x.ai/...` verification link (prefilled
when the server provides one). Tokens are stored under the `xai-oauth` tenant in
`auth.json` and refreshed automatically. Select the provider with
`OPENAB_AGENT_PROVIDER=xai` or a `xai/`-prefixed model (e.g.
`OPENAB_AGENT_MODEL=xai/grok-4.5`); xAI is not part of auto-detection.

### Adding an OAuth vendor

Subscription-OAuth providers are declared as a single `OAuthVendor` descriptor
Expand Down
25 changes: 25 additions & 0 deletions openab-agent/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,16 @@ impl AcpServer {
Err(e) => return self.error_response(id, -32000, &e),
}
}
"xai" | "grok" => {
let res = match model_override {
Some(m) => crate::llm::XaiProvider::from_auth_store_with_model(m),
None => crate::llm::XaiProvider::from_auth_store(),
};
match res {
Ok(p) => (Box::new(p), "xai"),
Err(e) => return self.error_response(id, -32000, &e),
}
}
_ => {
// Auto-detect: Anthropic (API key or OAuth) first, then codex.
let anthropic_res = match model_override {
Expand Down Expand Up @@ -462,6 +472,9 @@ impl AcpServer {
if crate::auth::load_tokens().is_ok() {
models.extend(Self::static_openai_models());
}
if crate::auth::load_tokens_for(crate::auth::XAI_NAMESPACE).is_ok() {
models.extend(Self::static_xai_models());
}
if models.is_empty() {
models.push(ModelOption::new(
"none",
Expand Down Expand Up @@ -505,6 +518,16 @@ impl AcpServer {
]
}

fn static_xai_models() -> Vec<ModelOption> {
// Static list matching Pi's trimmed xAI built-in models (earendil-works/pi
// #6734); grok-4.5 is the default the provider falls back to.
vec![
ModelOption::new("grok-4.5", "Grok 4.5", "xai"),
ModelOption::new("grok-4.3", "Grok 4.3", "xai"),
ModelOption::new("grok-build-0.1", "Grok Build 0.1", "xai"),
]
}

async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec<String> {
let session_id = params
.get("sessionId")
Expand Down Expand Up @@ -603,6 +626,8 @@ impl AcpServer {
AnthropicProvider::from_oauth_auto_with_model(value).map(|p| Box::new(p) as _)
}
"anthropic" => AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _),
"xai" => crate::llm::XaiProvider::from_auth_store_with_model(value)
.map(|p| Box::new(p) as _),
_ => crate::llm::OpenAiProvider::from_auth_store_with_model(value)
.map(|p| Box::new(p) as _),
};
Expand Down
54 changes: 52 additions & 2 deletions openab-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,11 @@ impl Agent {
content: assistant_content,
});

if tool_calls.is_empty() || !text_parts.is_empty() {
// No tool calls — we're done
// Done only when the turn carries no tool calls. A turn with BOTH
// text and tool_calls (common on Chat Completions — commentary
// before the call) must keep looping so the tools actually run;
// the text is already preserved in the assistant message above.
if tool_calls.is_empty() {
final_text = text_parts.join("");
break;
}
Expand Down Expand Up @@ -372,6 +375,53 @@ mod tests {
assert_eq!(result, "Hello!");
}

#[tokio::test]
async fn test_agent_mixed_text_and_tool_call_still_executes_tools() {
// Review F1: a turn carrying BOTH commentary text and tool_calls (common
// on Chat Completions) must keep looping so the tools actually run — not
// end the turn with the commentary while the calls sit unexecuted in
// history. The unknown tool name fails fast without touching the fs, so
// this exercises the full round-trip as a unit test.
let mock = MockLlmProvider::new(vec![
vec![
LlmEvent::Text("Let me check.".to_string()),
LlmEvent::ToolUse {
id: "tu_1".to_string(),
name: "no_such_tool".to_string(),
input: serde_json::json!({}),
},
],
vec![LlmEvent::Text("Done.".to_string()), LlmEvent::Stop],
]);

let tmp = tempfile::TempDir::new().unwrap();
let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string());
let result = agent.run("go").await.unwrap();
// The second LLM turn's text is the final reply — proof the loop continued.
assert_eq!(result, "Done.");

// user, assistant(text+tool_use), user(tool_result), assistant(text)
assert_eq!(agent.messages.len(), 4);
match &agent.messages[1].content[..] {
[ContentBlock::Text { text }, ContentBlock::ToolUse { name, .. }] => {
assert_eq!(text, "Let me check.");
assert_eq!(name, "no_such_tool");
}
other => panic!("unexpected assistant content: {other:?}"),
}
match &agent.messages[2].content[0] {
ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} => {
assert_eq!(tool_use_id, "tu_1");
assert_eq!(*is_error, Some(true));
}
other => panic!("expected ToolResult, got {other:?}"),
}
}

#[tokio::test]
#[ignore] // Integration test: executes real file tools
async fn test_agent_tool_call_then_response() {
Expand Down
Loading
Loading