Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Alan stores credentials at `~/.alan/auth.json` by default. Set `ALAN_HOME` to ch
matches anywhere; `@src/co` matches by directory).
- Slash commands:
- `/login` — sign in to a provider interactively
- `/settings` — view and change settings
- `/plan` — toggle plan mode (also `Shift+Tab`)
- `/help` — list available commands
- Key bindings: `Esc` clears input/selection, `Ctrl+C` interrupts the agent,
Expand All @@ -67,14 +68,35 @@ All variables are optional:
| `ALAN_MODEL` | Model id | `openai/gpt-4o-mini` |
| `ALAN_HOME` | Alan home directory (Alan uses `$ALAN_HOME/.alan/`) | `$HOME` |
| `ALAN_SESSION` | Resume this session ID from the current working directory | unset |
| `ALAN_REASONING_EFFORT` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | unset |
| `ALAN_REASONING_EFFORT` | `auto` (model decides), `none` (off), `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | `auto` |
| `ALAN_OPENROUTER_WEB_SEARCH` | Enable web search tool (`1`, `true`, `yes`, `on`) | off |
| `ALAN_OPENROUTER_WEB_FETCH` | Enable web fetch tool (`1`, `true`, `yes`, `on`) | off |
| `ALAN_LOG` | Log filter (falls back to `RUST_LOG`) | unset |
| `ALAN_LOG_DIR` | Where daily log files go | `$ALAN_HOME/.alan/logs` |

Files live under `$ALAN_HOME/.alan/`: `auth.json` (credentials),
`sessions/` (conversation history), and `logs/`.
Files live under `$ALAN_HOME/.alan/`: `settings.json` (see below), `auth.json`
(credentials), `sessions/` (conversation history), and `logs/`.

## Settings

Run `/settings` to view and change settings. `↑↓` moves, `Enter` cycles a value
or opens a prompt, `Backspace` clears a row, `Tab` switches between project and
global scope. Each row shows which layer its value came from.

Settings live in `$ALAN_HOME/.alan/settings.json` and
`<project>/.alan/settings.json`. Neither is created until you change something,
and each lists only the keys you set:

```json
{
"model": "anthropic/claude-opus-4",
"reasoning_effort": "high",
"max_tool_rounds": 30,
"tools": { "web_search": true }
}
```

Project settings override global ones, and environment variables override both.

## Development

Expand Down
15 changes: 15 additions & 0 deletions crates/agent/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ impl Agent {
.map(|session| session.id.clone())
}

/// Swap the bound model for the rest of this session.
pub async fn set_model(&self, model: Model) -> Result<(), AgentError> {
persistence::persist_model(
self,
model.info().provider.0.clone(),
model.info().id.clone(),
model.reasoning_effort(),
)
.await?;

*self.model.lock().await = model;

Ok(())
}

pub fn set_mode(&self, mode: Mode) {
self.mode.store(mode.as_u8(), Ordering::Release);
self.review_intro_pending
Expand Down
20 changes: 20 additions & 0 deletions crates/agent/src/agent/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ async fn persist_message(agent: &Agent, message: &AgentMessage) -> Result<(), Ag
Ok(())
}

/// Applies the change in memory too, so both describe the same thing.
pub(super) async fn persist_model(
agent: &Agent,
provider: String,
model: String,
reasoning_effort: llm::ReasoningEffort,
) -> Result<(), AgentError> {
let mut active_session = agent.active_session.lock().await;
let (Some(manager), Some(session)) = (&agent.session_manager, &mut *active_session) else {
return Ok(());
};

let record = manager
.append_model(&session.id, &session.pwd, provider, model, reasoning_effort)
.await?;
session.record(record);

Ok(())
}

pub(super) async fn persist_usage(agent: &Agent, usage: &Usage) -> Result<(), AgentError> {
let active_session = agent.active_session.lock().await;
if let (Some(manager), Some(session)) = (&agent.session_manager, &*active_session) {
Expand Down
4 changes: 2 additions & 2 deletions crates/agent/src/agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ async fn resumed_agent_includes_restored_messages_in_first_request() {
let manager = Arc::new(SessionManager::new(&root));

let session = manager
.create(&root, "openrouter", "test", None)
.create(&root, "openrouter", "test", llm::ReasoningEffort::Auto)
.await
.expect("create session");
manager
Expand Down Expand Up @@ -898,7 +898,7 @@ async fn session_images_roundtrip_through_reload() {
let manager = Arc::new(SessionManager::new(&root));

let session = manager
.create(&root, "openrouter", "test", None)
.create(&root, "openrouter", "test", llm::ReasoningEffort::Auto)
.await
.expect("create session");
let images = vec![llm::ImageUrl {
Expand Down
78 changes: 58 additions & 20 deletions crates/agent/src/session/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ impl SessionManager {
pwd: impl Into<PathBuf>,
provider: impl Into<String>,
model: impl Into<String>,
thinking_level: Option<ReasoningEffort>,
reasoning_effort: ReasoningEffort,
) -> Result<Session, SessionError> {
let pwd = normalize(pwd.into())?;
let key = pwd_key(&pwd);
let session = Session::new(pwd, provider, model, thinking_level);
let session = Session::new(pwd, provider, model, reasoning_effort);
validate_session_id(&session.id)?;
let path = self.file_path(&key, &session.id);
// `file_path` always builds `root/key/name.jsonl`, so parent is
Expand Down Expand Up @@ -102,6 +102,25 @@ impl SessionManager {
self.append_record(session_id, pwd, &record).await
}

pub async fn append_model(
&self,
session_id: &str,
pwd: &Path,
provider: impl Into<String>,
model: impl Into<String>,
reasoning_effort: ReasoningEffort,
) -> Result<SessionRecord, SessionError> {
let record = SessionRecord::Model {
provider: provider.into(),
model: model.into(),
reasoning_effort,
timestamp_ms: now_ms(),
};

self.append_record(session_id, pwd, &record).await?;
Ok(record)
}

pub async fn get_session(&self, session_id: &str, pwd: &Path) -> Result<Session, SessionError> {
validate_session_id(session_id)?;
let normalized = normalize(pwd.to_path_buf())?;
Expand Down Expand Up @@ -203,7 +222,7 @@ fn parse_header(
pwd,
provider,
model,
thinking_level,
reasoning_effort,
created_at_ms,
updated_at_ms,
} = record
Expand Down Expand Up @@ -244,7 +263,7 @@ fn parse_header(
pwd,
provider,
model,
thinking_level,
reasoning_effort,
messages: Vec::new(),
usage: Usage::default(),
created_at_ms,
Expand Down Expand Up @@ -287,7 +306,12 @@ mod tests {
let manager = SessionManager::new(&root);

let session = manager
.create("/tmp/project", "openrouter", "test-model", None)
.create(
"/tmp/project",
"openrouter",
"test-model",
ReasoningEffort::Auto,
)
.await
.expect("create session");

Expand All @@ -314,7 +338,12 @@ mod tests {
let manager = SessionManager::new(&root);

manager
.create("/tmp/project", "openrouter", "test-model", None)
.create(
"/tmp/project",
"openrouter",
"test-model",
ReasoningEffort::Auto,
)
.await
.expect("create session without a pre-existing root");

Expand All @@ -338,11 +367,11 @@ mod tests {
let manager = SessionManager::new(&root);

manager
.create("/tmp/a", "openrouter", "m", None)
.create("/tmp/a", "openrouter", "m", ReasoningEffort::Auto)
.await
.expect("create a");
manager
.create("/tmp/b", "openrouter", "m", None)
.create("/tmp/b", "openrouter", "m", ReasoningEffort::Auto)
.await
.expect("create b");

Expand All @@ -361,7 +390,7 @@ mod tests {
"/tmp/project",
"openrouter",
"test-model",
Some(ReasoningEffort::High),
ReasoningEffort::High,
)
.await
.expect("create");
Expand Down Expand Up @@ -393,7 +422,7 @@ mod tests {
assert_eq!(loaded.pwd, session.pwd);
assert_eq!(loaded.provider, "openrouter");
assert_eq!(loaded.model, "test-model");
assert_eq!(loaded.thinking_level, Some(ReasoningEffort::High));
assert_eq!(loaded.reasoning_effort, ReasoningEffort::High);
assert_eq!(loaded.created_at_ms, session.created_at_ms);
assert_eq!(
loaded.messages,
Expand All @@ -408,7 +437,7 @@ mod tests {
let root = temp_root("usage");
let manager = SessionManager::new(&root);
let session = manager
.create("/tmp/p", "o", "m", None)
.create("/tmp/p", "o", "m", ReasoningEffort::Auto)
.await
.expect("create");

Expand All @@ -434,7 +463,7 @@ mod tests {
let root = temp_root("truncated");
let manager = SessionManager::new(&root);
let session = manager
.create("/tmp/p", "o", "m", None)
.create("/tmp/p", "o", "m", ReasoningEffort::Auto)
.await
.expect("create");
let path = session_file(&root, "/tmp/p", &session.id);
Expand Down Expand Up @@ -471,7 +500,7 @@ mod tests {
let root = temp_root("cross");
let manager = SessionManager::new(&root);
let session = manager
.create("/tmp/p", "o", "m", None)
.create("/tmp/p", "o", "m", ReasoningEffort::Auto)
.await
.expect("create");

Expand All @@ -496,14 +525,14 @@ mod tests {
let pwd = "/tmp/p";

let session = manager
.create(pwd, "o", "m", None)
.create(pwd, "o", "m", ReasoningEffort::Auto)
.await
.expect("first create");
let before = std::fs::read_to_string(session_file(&root, pwd, &session.id)).unwrap();

// Simulate a second create racing onto the same id: the exclusive
// file creation must refuse rather than truncate the existing file.
let mut collision = Session::new(pwd, "o", "m", None);
let mut collision = Session::new(pwd, "o", "m", ReasoningEffort::Auto);
collision.id = session.id.clone();
collision.created_at_ms = 123_456;
collision.updated_at_ms = 123_456;
Expand All @@ -526,7 +555,10 @@ mod tests {
let root = temp_root("header");
let manager = SessionManager::new(&root);
let pwd = "/tmp/p";
let session = manager.create(pwd, "o", "m", None).await.expect("create");
let session = manager
.create(pwd, "o", "m", ReasoningEffort::Auto)
.await
.expect("create");
let path = session_file(&root, pwd, &session.id);

// Wrong schema version.
Expand All @@ -543,7 +575,10 @@ mod tests {
assert!(matches!(err, SessionError::UnsupportedVersion { .. }));

// Mismatched header id (fresh file).
let session = manager.create(pwd, "o", "m", None).await.expect("create");
let session = manager
.create(pwd, "o", "m", ReasoningEffort::Auto)
.await
.expect("create");
let path = session_file(&root, pwd, &session.id);
let content = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, content.replace(&session.id, "other-id")).unwrap();
Expand All @@ -554,7 +589,10 @@ mod tests {
assert!(matches!(err, SessionError::InvalidHeader { .. }));

// Mismatched header pwd / cross-directory load (fresh file).
let session = manager.create(pwd, "o", "m", None).await.expect("create");
let session = manager
.create(pwd, "o", "m", ReasoningEffort::Auto)
.await
.expect("create");
let path = session_file(&root, pwd, &session.id);
let content = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, content.replace(pwd, "/elsewhere")).unwrap();
Expand All @@ -570,7 +608,7 @@ mod tests {
async fn append_to_missing_session_fails() {
let root = temp_root("missing-append");
let manager = SessionManager::new(&root);
let session = Session::new("/tmp/p", "o", "m", None);
let session = Session::new("/tmp/p", "o", "m", ReasoningEffort::Auto);

let err = manager
.append_message(&session.id, &session.pwd, &AgentMessage::user("hi"))
Expand All @@ -588,7 +626,7 @@ mod tests {
let root = temp_root("perms");
let manager = SessionManager::new(&root);
let session = manager
.create("/tmp/p", "o", "m", None)
.create("/tmp/p", "o", "m", ReasoningEffort::Auto)
.await
.expect("create");

Expand Down
Loading