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
154 changes: 148 additions & 6 deletions client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
//! [sonny]
//! lead_effort = "medium" # a new lead's effort
//! task_effort = "high" # each new task's
//! lead_profile = "my-lead" # the role a new lead starts in, not lead
//! lead_profile = "my-lead" # the role a new lead starts in, not lead
//! task_profile = "worker" # what every other agent is told, not task
//! side_chat = "fork" # "answer", the default, calls no tool
//! chime = false # rings when work ends or waits, the default
//! verbose = true # every tool call drawn, not the clean view
//! suggest = false # no guess at the next message, on by default
//! unsandboxed = ["mcpx"] # commands run outside agents' sandboxes
//!
Expand Down Expand Up @@ -74,7 +77,7 @@ pub struct Judge {
pub effort: Option<String>,
}

/// What Sonny starts its agents with.
/// What Sonny starts its agents with, and how it shows them.
#[derive(Clone, Debug, PartialEq)]
pub struct Sonny {
pub lead_effort: Option<String>,
Expand All @@ -90,6 +93,12 @@ pub struct Sonny {
pub unsandboxed: Vec<String>,
/// The MCP servers Sonny's agents reach, by name.
pub mcp_servers: BTreeMap<String, McpServer>,
pub side_chat: SideChat,
/// Whether finished and waiting work rings. Notifications show either way.
pub chime: bool,
/// Whether every tool call is drawn, or only the bot's words and the
/// calls that move the work on (the clean view, the default).
pub verbose: bool,
/// Whether a copy of the agent guesses the person's next message after
/// a turn they started, shown in the empty composer for Tab to take.
pub suggest: bool,
Expand All @@ -106,12 +115,25 @@ impl Default for Sonny {
task_profile: None,
unsandboxed: Vec::new(),
mcp_servers: BTreeMap::new(),
side_chat: SideChat::default(),
suggest: true,
chime: true,
verbose: false,
sources: Vec::new(),
}
}
}

/// What the copy `/side` talks to may do: `"answer"`, the default, from
/// what its agent knows, calling no tool; or `"fork"`, anything its agent
/// may, with its tools, in its folder, under its approvals.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SideChat {
#[default]
Answer,
Fork,
}

/// A command Sonny's lookout runs, whose every printed line is news for
/// the agents subscribed to it by name.
#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -335,6 +357,9 @@ fn parse(text: &str) -> Result<Config, String> {
"task_effort",
"lead_profile",
"task_profile",
"side_chat",
"chime",
"verbose",
"suggest",
"unsandboxed",
],
Expand All @@ -358,8 +383,13 @@ fn parse(text: &str) -> Result<Config, String> {
if !keys.contains(&key.as_str()) {
return Err(format!("unknown key {name}.{key}"));
}
if key == "suggest" {
config.sonny.suggest = value
if let Some(switch) = match key.as_str() {
"chime" => Some(&mut config.sonny.chime),
"suggest" => Some(&mut config.sonny.suggest),
"verbose" => Some(&mut config.sonny.verbose),
_ => None,
} {
*switch = value
.as_bool()
.ok_or_else(|| format!("{name}.{key} must be true or false"))?;
continue;
Expand All @@ -377,6 +407,14 @@ fn parse(text: &str) -> Result<Config, String> {
continue;
}
let value = string(&format!("{name}.{key}"), value)?;
if key == "side_chat" {
config.sonny.side_chat = match value.as_str() {
"answer" => SideChat::Answer,
"fork" => SideChat::Fork,
other => return Err(format!("sonny.side_chat is answer or fork, not {other}")),
};
continue;
}
// The daemon judges models and levels when the agents are made.
*match (name.as_str(), key.as_str()) {
("judge", "model") => &mut config.judge.model,
Expand Down Expand Up @@ -439,6 +477,15 @@ fn string(key: &str, value: &toml::Value) -> Result<String, String> {
/// new one goes at the end of its table, or of the top level for a bare
/// key. The file is written whole, and only if it still reads.
pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> {
let pairs: Vec<(&str, Option<toml::Value>)> = (pairs.iter())
.map(|(k, v)| (*k, Some(toml::Value::String((*v).to_owned()))))
.collect();
write(path, &pairs)
}

/// As `set`, for any value, where a key with none has its line taken out,
/// so it is its default again; one the file does not name is left so.
pub fn write(path: &Path, pairs: &[(&str, Option<toml::Value>)]) -> Result<(), String> {
read(path)?;
let text = match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Expand All @@ -447,7 +494,6 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> {
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
for (key, value) in pairs {
let (table, name) = key.split_once('.').unwrap_or(("", key));
let line = format!("{name} = {}", toml::Value::String((*value).to_owned()));
let at = |line: &str| {
line.trim_start().strip_prefix('[').map(|h| {
h.trim_start_matches('[')
Expand All @@ -460,10 +506,12 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> {
};
// The lines of `table`: after its header, or the top level's
// before the first header, up to the next header.
let header = lines.iter().position(|l| at(l).as_deref() == Some(table));
let begin = match table {
"" => 0,
_ => match lines.iter().position(|l| at(l).as_deref() == Some(table)) {
_ => match header {
Some(header) => header + 1,
None if value.is_none() => continue,
Comment thread
lydakis marked this conversation as resolved.
Resolved
None => {
if lines.last().is_some_and(|l| !l.trim().is_empty()) {
lines.push(String::new());
Expand All @@ -481,6 +529,19 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> {
.split_once('=')
.is_some_and(|(k, _)| k.trim().trim_matches('"') == name)
});
let Some(value) = value else {
if let Some(i) = named {
lines.remove(i);
// A table left with nothing in it goes too.
if let Some(header) = header
&& lines[begin..end - 1].iter().all(|l| l.trim().is_empty())
{
lines.drain(header..end - 1);
}
}
continue;
};
let line = format!("{name} = {value}");
match named {
Some(i) => lines[i] = line,
None => {
Expand All @@ -495,8 +556,34 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> {
}
}
}
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
let before = text;
let mut text = lines.join("\n");
text.push('\n');
// A key written another way, as a dotted key or an inline table, is
// not one the lines above find: it is refused, not left as it was.
let table: toml::Table = text.parse().unwrap_or_default();
for (key, value) in pairs {
let (name, inner) = key
.split_once('.')
.map_or((*key, None), |(t, k)| (t, Some(k)));
let found = match inner {
Some(inner) => table.get(name).and_then(|t| t.get(inner)),
None => table.get(name),
};
if found != value.as_ref() {
return Err(format!(
"config_unwritable: {}: {key} is written in a form only an editor can change",
path.display()
));
}
}
// Nothing changed, as a default taken out of a file without it.
if text == before || (before.is_empty() && lines.is_empty()) {
return Ok(());
}
replace(path, &text)
}

Expand Down Expand Up @@ -591,6 +678,12 @@ mod tests {
let off = parse("web_search = \"disabled\"").unwrap();
assert_eq!(off.web_search, WebSearch::Disabled);
assert!(!config.hide_senders);
assert_eq!(config.sonny.side_chat, SideChat::Answer);
let fork = parse("[sonny]\nside_chat = \"fork\"").unwrap();
assert_eq!(fork.sonny.side_chat, SideChat::Fork);
assert!(config.sonny.chime && !config.sonny.verbose);
let quiet = parse("[sonny]\nchime = false\nverbose = true").unwrap();
assert!(!quiet.sonny.chime && quiet.sonny.verbose);
assert!(parse("senders = \"hidden\"").unwrap().hide_senders);
assert!(!parse("senders = \"shown\"").unwrap().hide_senders);
for (text, error) in [
Expand All @@ -600,6 +693,14 @@ mod tests {
),
("web_search = false", "web_search must be a string"),
("senders = \"off\"", "senders is shown or hidden, not off"),
(
"[sonny]\nchime = \"no\"",
"sonny.chime must be true or false",
),
(
"[sonny]\nside_chat = \"all\"",
"sonny.side_chat is answer or fork, not all",
),
("colour = \"red\"", "unknown key colour"),
("model = 3", "model must be a string"),
("[web]\nsearch = true", "unknown table [web]"),
Expand Down Expand Up @@ -753,6 +854,47 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}

#[test]
fn a_key_written_with_no_value_is_its_default_again() {
let dir = std::env::temp_dir().join(format!("agent-config-unset-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let file = dir.join("config.toml");
// Nothing to take out leaves no file and no table behind.
write(&file, &[("judge.model", None)]).unwrap();
assert!(!file.exists());
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
&file,
"# mine\nmodel = \"a/b\"\n\n[judge]\neffort = \"low\"\n\n[sonny]\nlead_effort = \"low\" # cheap\nside_chat = \"fork\"\n",
)
.unwrap();
write(
&file,
&[
("sonny.lead_effort", None),
("sonny.task_effort", Some("high".into())),
("sonny.chime", Some(false.into())),
("model", None),
("judge.effort", None),
],
)
.unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"# mine\n\n[sonny]\nside_chat = \"fork\"\ntask_effort = \"high\"\nchime = false\n"
);
// A key the lines cannot find is refused, the file kept.
for text in ["sonny.chime = false\n", "sonny = { chime = false }\n"] {
std::fs::write(&file, text).unwrap();
for value in [None, Some(true.into())] {
let error = write(&file, &[("sonny.chime", value)]).unwrap_err();
assert!(error.starts_with("config_"), "{text}: {error}");
assert_eq!(std::fs::read_to_string(&file).unwrap(), text);
}
}
std::fs::remove_dir_all(&dir).unwrap();
}

#[test]
fn a_table_is_replaced_whole_and_the_rest_kept() {
let dir = std::env::temp_dir().join(format!("agent-config-table-{}", std::process::id()));
Expand Down
Binary file added docs/sonny/config.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sonny/side-fork.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 29 additions & 9 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ at its model's own effort (`AGENT_EFFORT` when its model is
| Each new task's model | (the lead's) | `threads_model` |
| Each new task's effort | `[sonny] task_effort` | `threads_effort` |
| The approver's and lookout's judge | `[judge] model`, `effort` | |
| What `/side`'s copy may do | `[sonny] side_chat` (`answer` or `fork`) | |
| The chime, and every tool call drawn | `[sonny] chime`, `verbose` (true or false) | |
| A guess at your next message | `[sonny] suggest` (true, the default, or false) | |
| News from outside for the lookout | `[sonny.sources.NAME] command`, `every` | |

Expand Down Expand Up @@ -324,14 +326,29 @@ off again never shows.
- `/side [QUESTION]` opens a side chat with a copy of the agent in this
window, taken where it is now, mid-turn included: in the pane beside
the conversation on the full screen, on the alternate screen inline.
The copy answers from the agent's history and may call no tool;
follow-ups stay with it, and Esc goes back. In the pane, every key goes
to the side chat but Shift+Tab, which goes back to the main composer
while the side chat stays open. The agent carries on meanwhile, in
view on the full screen, waiting on the main screen inline. The daemon
deletes the copy once it has been idle 15 minutes (`fork --expires`), and
no copy shows among the project's agents, in `/status`, or to the
lookout.
The copy answers from the agent's history and may call no tool, unless
`~/.agent/config.toml` sets `[sonny] side_chat = "fork"`: then it is a
full fork that can do anything the agent can, with its tools, in its
folder, under its approvals. Follow-ups stay with it, and Esc goes
back. In the pane, every key goes to the side chat but Shift+Tab, which
goes back to the main composer while the side chat stays open. The agent
carries on meanwhile, in view on the full screen, waiting on the main
screen inline. The daemon deletes the copy once it has been idle 15
minutes (`fork --expires`), and no copy shows among the project's
agents, in `/status`, or to the lookout.

![A side chat forked with every tool writes a file beside the lead](../docs/sonny/side-fork.png)
- `/config` (or `/settings`) lists every key `~/.agent/config.toml` takes,
with its value or what its default is, over the composer. ↑/↓ picks
one; ←/→ steps a key with a few values (efforts, `web_search`,
`senders`, `side_chat`, `chime`, `verbose`, `suggest`) through them
and its default; Enter types one that takes a name (`model`, `provider`,
`judge.model`, the profiles), empty for its default; ⌫
takes a key back to its default. Each change is written at once, the
file's comments and order kept, and a file that does not read is
refused by name, as every reader refuses it.

![/config over the composer, side_chat set to fork](../docs/sonny/config.png)
- `/model` picks the lead's model, from every model its provider family
lists, and its effort (←/→), and writes them to the project's
`.agents/project.toml` when the folder has one. A pick made while a turn
Expand Down Expand Up @@ -489,7 +506,10 @@ turn of the task numbered after that one reopens it.

![/status with the lead first, then its tasks under Needs you, Working, Ready for review, Idle and the resolved count](../docs/sonny/status-groups.png)

Settings Sonny keeps for every folder are in `~/.agent/sonny/settings.json`.
Settings Sonny keeps for every folder are in `~/.agent/config.toml`'s
`[sonny]`, which `/config` shows: the chime (`chime`, on by default) and
the view (`verbose`, off by default), besides the efforts and the side chat
above; only the screen is in `~/.agent/sonny/settings.json`.
At each start Sonny writes what every agent leans on: the
`memory`, `memory-cleanup`, `plan` and `automation` skills, as normal skills
in `~/.agents/skills` (replacing links the Agent app left there), and
Expand Down
Loading
Loading