Skip to content

Commit 57d1b7b

Browse files
authored
feat(prompt): add system prompt builder with context injection (#7)
## Summary Add a section-based system prompt builder with runtime environment detection, hierarchical instruction file discovery (CLAUDE.md / AGENTS.md), and static guidance sections. The prompt is rebuilt each turn so git status and branch info stay fresh. The API client now sends the system prompt as an array of text blocks with the identity prefix in its own block, which is required for OAuth validation on non-Haiku models. - Section-based prompt assembly: identity, task guidance, caution, tool usage, tone / style, environment, user instructions. - CLAUDE.md / AGENTS.md discovery with root-to-CWD walk and `.claude/` subdirectory support. - Runtime environment detection: working directory, platform, shell, git branch + clean / dirty status, local date, model name. - Per-turn prompt rebuild for fresh git state on every user message. - System prompt sent as array of `TextBlock` with identity prefix in its own block (OAuth requirement). - Research docs on system prompt architecture and API authentication (block format, attribution header, third-party restrictions). - User-facing guide: quickstart, configuration, instruction files. ## Changes | File | Description | | ---- | ----------- | | `crates/oxide-code/src/prompt.rs` | Module root: static guidance constants, `build_system_prompt` entry, `assemble` (testable core), `find_git_root` | | `crates/oxide-code/src/prompt/environment.rs` | `Environment` struct with `detect` / `render`, parallel git info via `tokio::join!`, `current_date` via `time` crate with local-offset fallback | | `crates/oxide-code/src/prompt/instructions.rs` | `Slot`-based instruction discovery: `load` → `candidate_slots` → `walk_root_to_cwd` → `load_files` → `render` | | `crates/oxide-code/src/client/anthropic.rs` | `SystemBlock` type, `system` field changed from `&str` to `Vec<SystemBlock>`, prefix sent as separate block, `SYSTEM_PROMPT_PREFIX` moved here | | `crates/oxide-code/src/main.rs` | Per-turn `build_system_prompt` call, pass `Some(&system_prompt)` to client | | `crates/oxide-code/src/config/oauth.rs` | `_ =` style consistency | | `Cargo.toml` | Add `time` with `local-offset` feature | | `CLAUDE.md` | Add `prompt.rs`, `prompt/environment.rs`, `prompt/instructions.rs` to crate structure | | `docs/research/system-prompt.md` | Research: Claude Code and opencode prompt architecture, section caching, walk behavior | | `docs/research/anthropic-api.md` | Research: system block format, attribution header / fingerprint, third-party restrictions | | `docs/guide/quickstart.md` | User guide: install, credentials, first session, tools table | | `docs/guide/configuration.md` | User guide: API key, OAuth, environment variables | | `docs/guide/instructions.md` | User guide: CLAUDE.md / AGENTS.md discovery hierarchy, walk example | | `docs/guide/README.md` | Guide index | | `docs/README.md` | Add User Guide section | | `docs/roadmap.md` | Move system prompt to Working, update focus | | `README.md` | Slim down, add Documentation table linking to guide | ## Test plan - [x] `cargo build` compiles cleanly - [x] `cargo clippy --all-targets -- -D warnings` — zero warnings - [x] `cargo test` — 195 tests pass - [x] `cargo llvm-cov --ignore-filename-regex 'main\.rs'` — 89% line coverage (prompt modules 98–99%) - [x] Live test: `ox` responds correctly with full system prompt (CLAUDE.md injected, environment detected) - [x] Live test: `main` branch works, `feat/system-prompt` works after block-format fix
1 parent 687d557 commit 57d1b7b

20 files changed

Lines changed: 1466 additions & 52 deletions

.cspell/words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
anthropic
22
anyhow
3+
claudemd
34
clippy
45
codex
56
creds

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ ox # Start an interactive session
3131
│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking
3232
├── main.rs # CLI entry point, agent loop, async REPL
3333
├── message.rs # Conversation message types
34+
├── prompt.rs # System prompt builder (section assembly, static content)
35+
├── prompt/
36+
│ ├── environment.rs # Runtime environment detection (platform, git, date)
37+
│ └── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md)
3438
├── tool.rs # Tool trait, registry, definitions
3539
└── tool/
3640
├── bash.rs # Shell command execution with timeout

Cargo.lock

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ security-framework = "3"
3333
serde = { version = "1", features = ["derive"] }
3434
serde_json = "1"
3535
tempfile = "3"
36+
time = { version = "0.3", features = ["local-offset"] }
3637
tokio = { version = "1", features = [
3738
"io-std",
3839
"io-util",

README.md

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,17 @@ Early development. See [`docs/roadmap.md`](docs/roadmap.md) for the current road
1818
## Usage
1919

2020
```bash
21+
export ANTHROPIC_API_KEY=sk-ant-...
2122
ox
2223
```
2324

24-
## Configuration
25+
## Documentation
2526

26-
oxide-code needs an Anthropic API credential. It checks two sources in order:
27-
28-
1. **`ANTHROPIC_API_KEY`** — set this to your Anthropic API key.
29-
2. **Claude Code OAuth** — if no API key is set, oxide-code reads OAuth credentials from the macOS Keychain and `~/.claude/.credentials.json` (created by [Claude Code]), preferring whichever has the later expiry. Falls back to file-only on Linux.
30-
31-
Optional environment variables:
32-
33-
| Variable | Default | Description |
34-
| ---------------------- | --------------------------- | ----------------------- |
35-
| `ANTHROPIC_MODEL` | `claude-opus-4-6` | Model to use |
36-
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | API base URL |
37-
| `ANTHROPIC_MAX_TOKENS` | `16384` | Max tokens per response |
27+
| Document | Description |
28+
| ----------------------------------------------- | ----------------------------------------------- |
29+
| [Quickstart](docs/guide/quickstart.md) | Install, first run, basic usage |
30+
| [Configuration](docs/guide/configuration.md) | API credentials, model selection, environment |
31+
| [Instruction Files](docs/guide/instructions.md) | CLAUDE.md / AGENTS.md setup and discovery rules |
3832

3933
## Building from Source
4034

crates/oxide-code/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ regex.workspace = true
2323
reqwest.workspace = true
2424
serde.workspace = true
2525
serde_json.workspace = true
26+
time.workspace = true
2627
tokio.workspace = true
2728
tracing.workspace = true
2829
tracing-subscriber.workspace = true

crates/oxide-code/src/client/anthropic.rs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20";
1717
/// Matches the referenced Claude Code version.
1818
const CLAUDE_CLI_VERSION: &str = "2.1.87";
1919

20-
/// System prompt prefix that identifies the client to the Anthropic API. Required
21-
/// for OAuth tokens — without it, non-Haiku models return 429. Always sent
22-
/// regardless of auth method for simplicity.
20+
/// OAuth-required identity prefix. The Anthropic API returns 429 for non-Haiku
21+
/// models with OAuth tokens unless the system prompt starts with this exact
22+
/// string in its own text block.
2323
const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
2424

2525
// ── Request types ──
@@ -29,14 +29,24 @@ struct CreateMessageRequest<'a> {
2929
model: &'a str,
3030
max_tokens: u32,
3131
messages: &'a [Message],
32-
system: &'a str,
32+
system: Vec<SystemBlock<'a>>,
3333
stream: bool,
3434
#[serde(skip_serializing_if = "Option::is_none")]
3535
tools: Option<&'a [ToolDefinition]>,
3636
#[serde(skip_serializing_if = "Option::is_none")]
3737
thinking: Option<&'a ThinkingConfig>,
3838
}
3939

40+
/// A text block in the system prompt array. The Anthropic API accepts `system`
41+
/// as either a string or an array of these blocks. Using the array form lets
42+
/// the identity prefix occupy its own block, which is required for OAuth
43+
/// validation on non-Haiku models.
44+
#[derive(Serialize)]
45+
struct SystemBlock<'a> {
46+
r#type: &'static str,
47+
text: &'a str,
48+
}
49+
4050
// ── SSE response types ──
4151

4252
#[expect(
@@ -230,17 +240,23 @@ impl Client {
230240
system: Option<&str>,
231241
tools: &[ToolDefinition],
232242
) -> Result<mpsc::Receiver<Result<StreamEvent>>> {
233-
let system_prompt = match system {
234-
Some(s) => format!("{SYSTEM_PROMPT_PREFIX}\n{s}"),
235-
None => SYSTEM_PROMPT_PREFIX.to_owned(),
236-
};
243+
let mut system_blocks = vec![SystemBlock {
244+
r#type: "text",
245+
text: SYSTEM_PROMPT_PREFIX,
246+
}];
247+
if let Some(s) = system {
248+
system_blocks.push(SystemBlock {
249+
r#type: "text",
250+
text: s,
251+
});
252+
}
237253

238254
let url = format!("{}/v1/messages", self.config.base_url);
239255
let body = serde_json::to_value(CreateMessageRequest {
240256
model: &self.config.model,
241257
max_tokens: self.config.max_tokens,
242258
messages,
243-
system: &system_prompt,
259+
system: system_blocks,
244260
stream: true,
245261
tools: (!tools.is_empty()).then_some(tools),
246262
thinking: self.config.thinking.as_ref(),
@@ -253,7 +269,7 @@ impl Client {
253269
tokio::spawn(async move {
254270
let result = stream_sse(&http, &url, &body, &tx).await;
255271
if let Err(e) = result {
256-
let _ = tx.send(Err(e)).await;
272+
_ = tx.send(Err(e)).await;
257273
}
258274
});
259275

crates/oxide-code/src/config/oauth.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ struct LockGuard {
302302

303303
impl Drop for LockGuard {
304304
fn drop(&mut self) {
305-
let _ = std::fs::remove_dir_all(&self.path);
305+
_ = std::fs::remove_dir_all(&self.path);
306306
}
307307
}
308308

@@ -318,7 +318,7 @@ async fn acquire_lock(path: &Path) -> Result<LockGuard> {
318318
}
319319
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
320320
if is_stale_lock(path) {
321-
let _ = std::fs::remove_dir_all(path);
321+
_ = std::fs::remove_dir_all(path);
322322
continue;
323323
}
324324
if attempt == LOCK_MAX_RETRIES {

crates/oxide-code/src/main.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod client;
22
mod config;
33
mod message;
4+
mod prompt;
45
mod tool;
56

67
use std::io::Write;
@@ -34,6 +35,7 @@ async fn main() -> Result<()> {
3435

3536
let config = Config::load().await?;
3637
let show_thinking = config.show_thinking;
38+
let model = config.model.clone();
3739
let client = Client::new(config)?;
3840
let tools = ToolRegistry::new(vec![
3941
Box::new(BashTool),
@@ -44,10 +46,15 @@ async fn main() -> Result<()> {
4446
Box::new(GrepTool),
4547
]);
4648

47-
repl(&client, &tools, show_thinking).await
49+
repl(&client, &tools, &model, show_thinking).await
4850
}
4951

50-
async fn repl(client: &Client, tools: &ToolRegistry, show_thinking: bool) -> Result<()> {
52+
async fn repl(
53+
client: &Client,
54+
tools: &ToolRegistry,
55+
model: &str,
56+
show_thinking: bool,
57+
) -> Result<()> {
5158
let stdin = BufReader::new(tokio::io::stdin());
5259
let mut lines = stdin.lines();
5360
let mut messages: Vec<Message> = Vec::new();
@@ -66,7 +73,8 @@ async fn repl(client: &Client, tools: &ToolRegistry, show_thinking: bool) -> Res
6673
}
6774

6875
messages.push(Message::user(&input));
69-
agent_turn(client, tools, &mut messages, show_thinking).await?;
76+
let system_prompt = prompt::build_system_prompt(model).await;
77+
agent_turn(client, tools, &mut messages, &system_prompt, show_thinking).await?;
7078
}
7179

7280
Ok(())
@@ -76,13 +84,15 @@ async fn agent_turn(
7684
client: &Client,
7785
tools: &ToolRegistry,
7886
messages: &mut Vec<Message>,
87+
system_prompt: &str,
7988
show_thinking: bool,
8089
) -> Result<()> {
8190
let tool_defs = tools.definitions();
8291

8392
for _ in 0..MAX_TOOL_ROUNDS {
8493
strip_trailing_thinking(messages);
85-
let blocks = stream_response(client, messages, &tool_defs, show_thinking).await?;
94+
let blocks =
95+
stream_response(client, messages, &tool_defs, system_prompt, show_thinking).await?;
8696

8797
let tool_uses: Vec<_> = blocks
8898
.iter()
@@ -205,9 +215,10 @@ async fn stream_response(
205215
client: &Client,
206216
messages: &[Message],
207217
tools: &[ToolDefinition],
218+
system_prompt: &str,
208219
show_thinking: bool,
209220
) -> Result<Vec<ContentBlock>> {
210-
let mut rx = client.stream_message(messages, None, tools)?;
221+
let mut rx = client.stream_message(messages, Some(system_prompt), tools)?;
211222

212223
let mut blocks: Vec<Option<BlockAccumulator>> = Vec::new();
213224
let mut stdout = std::io::stdout();

0 commit comments

Comments
 (0)