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
34 changes: 34 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,37 @@ are `source`, `dest`, `trigger`, `extract`, and `prefix`.

Local LLMs require `endpoint` and `model`; `system` and `api_key` are optional.


- `tool_dedup_secs` (default `45`) — repeat-call suppression window for the orchestrator's tools. `0` disables.

## `[[personas]]`

Named behavioural presets layered over `[orchestrator]`. Every field is
optional; omitted fields inherit from `[orchestrator]`. Set
`[orchestrator].persona` to pick the one applied at startup, or switch at
runtime with `/persona <name>` (history is preserved).

Personas modulate autonomy and eagerness only. Repeat-call suppression,
tool-result elision stubs and `send_input` evidence are unconditional — a
persona cannot turn correctness off.

- `name` — the name used by `/persona`. Matching a builtin replaces it.
- `events`, `event_cooldown_secs` — which session transitions wake the agent, and how often.
- `approval`, `auto_approve` — propose-mode gating.
- `allowed_tools` — tools present in the schema at all. `list_sessions` is always kept.
- `max_tool_iterations`, `tool_dedup_secs`, `max_context_tokens`, `event_tail_lines`.
- `note` — appended to the system prompt under a `## Persona` heading.

Builtins: `assistant` (reactive, read-only, propose), `monitor` (watches and
reports, writes gated), `orchestrator` (acts autonomously, tightest dedup
window).

```toml
[orchestrator]
persona = "monitor"

[[personas]]
name = "monitor"
event_cooldown_secs = 90
note = "Be terse. One report per incident."
```
136 changes: 134 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,11 @@ pub struct App {
/// Live progress of the orchestrator's current turn, plus when it was
/// set (drives the chat-pane spinner). None while idle.
pub orchestrator_status: Option<(String, std::time::Instant)>,
/// Name of the active persona (empty = bare [orchestrator] config).
pub orchestrator_persona: String,
/// Pristine [orchestrator] config. Personas layer over *this*, never over
/// an already-layered config, so swapping A -> B -> A is idempotent.
pub orchestrator_base: Option<crate::config::OrchestratorConfig>,
/// Per-(session_id, state label) cooldown for proactive orchestrator events
orch_event_cooldowns: HashMap<(usize, &'static str), std::time::Instant>,
/// Session behind the most recent permission request surfaced in chat;
Expand Down Expand Up @@ -604,6 +609,8 @@ impl App {
orchestrator_stats: crate::session::TokenStats::default(),
orchestrator_ctx_max: None,
orchestrator_status: None,
orchestrator_persona: String::new(),
orchestrator_base: None,
orch_event_cooldowns: HashMap::new(),
last_permission_request: None,
chat: ChatState::default(),
Expand Down Expand Up @@ -2310,6 +2317,29 @@ impl App {
anyhow::bail!("orchestrator already running");
}
self.orchestrator_paused = false;
// Remember the unlayered config once, then apply the configured
// default persona (if any) before anything reads cfg.
// Not get_or_insert_with: the closure would borrow self while
// orchestrator_base is already mutably borrowed.
let base = match &self.orchestrator_base {
Some(b) => b.clone(),
None => {
let b = self.config.orchestrator.clone();
self.orchestrator_base = Some(b.clone());
b
}
};
let want = base.persona.clone();
if !want.is_empty() && self.orchestrator_persona != want {
if let Some(p) = self.personas().into_iter().find(|p| p.name == want) {
let mut cfg = (*self.config).clone();
cfg.orchestrator = p.apply(&base);
self.config = Arc::new(cfg);
self.orchestrator_persona = want;
} else {
self.chat_system(format!("unknown persona \"{}\" in config; ignoring", want));
}
}
let cfg = self.config.orchestrator.clone();
match cfg.class()? {
crate::config::OrchestratorClass::Api(_) => {
Expand Down Expand Up @@ -2472,8 +2502,36 @@ impl App {
.insert(session_id, (response_tx, line_offset));
return; // reply arrives via check_ipc_replies on READY
}
s.write_bytes(shape_injected_input(&text));
serde_json::json!({"ok": true})
// Fire-and-forget, but never a bare {"ok": true}: an
// unconditional success is indistinguishable from a send into
// a paused or dead session, so the model cannot tell a no-op
// from a real action and retries. Report what is knowable
// synchronously and point at wait_ready for the rest.
if s.paused {
serde_json::json!({
"error": "session is paused (SIGSTOP); resume_session first — \
input would sit unread in the terminal buffer",
"session_id": session_id
})
} else if s.state_label().eq_ignore_ascii_case("dead") {
serde_json::json!({
"error": "session is dead; input was not sent",
"session_id": session_id
})
} else {
let before = s.output_lines.len();
s.write_bytes(shape_injected_input(&text));
serde_json::json!({
"ok": true,
"session_id": session_id,
"state_at_send": s.state_label(),
"output_lines_at_send": before,
"note": "input was written to the terminal; this is not \
confirmation it was accepted or acted on. Use \
wait_ready=true, or read_output later and compare \
against output_lines_at_send, to verify."
})
}
}
OrchestratorReq::PipeAdd {
source,
Expand Down Expand Up @@ -3771,6 +3829,8 @@ impl App {
.to_string()
};
}
["persona"] => self.report_personas(),
["persona", name] => self.set_persona(name),
["confirm-kill"] => self.resolve_pending_kill(true),
["deny-kill"] => self.resolve_pending_kill(false),
["interrupt"] | ["stop"] => self.interrupt_orchestrator(),
Expand All @@ -3785,6 +3845,78 @@ impl App {
}
}

/// All personas: user-defined entries in [[personas]] shadow builtins of
/// the same name.
fn personas(&self) -> Vec<crate::config::Persona> {
let mut out = crate::config::builtin_personas();
for p in &self.config.personas {
match out.iter_mut().find(|b| b.name == p.name) {
Some(slot) => *slot = p.clone(),
None => out.push(p.clone()),
}
}
out
}

fn report_personas(&mut self) {
let names: Vec<String> = self
.personas()
.iter()
.map(|p| {
if p.name == self.orchestrator_persona {
format!("{}*", p.name)
} else {
p.name.clone()
}
})
.collect();
self.command_result = format!("personas: {} (* = active)", names.join(", "));
}

/// Swap the active persona. History is preserved — a persona changes how
/// the agent behaves from here, not what it knows. In-flight tool calls
/// complete under the old config; the allowlist applies from the next turn.
fn set_persona(&mut self, name: &str) {
let Some(persona) = self.personas().into_iter().find(|p| p.name == name) else {
self.command_result = format!("unknown persona \"{}\"; try /persona", name);
return;
};
// Not get_or_insert_with: the closure would borrow self while
// orchestrator_base is already mutably borrowed.
let base = match &self.orchestrator_base {
Some(b) => b.clone(),
None => {
let b = self.config.orchestrator.clone();
self.orchestrator_base = Some(b.clone());
b
}
};
let cfg = persona.apply(&base);
let Some(h) = &self.orchestrator else {
// No API orchestrator running: still record it so a later
// /orchestrator start picks the persona up.
let mut config = (*self.config).clone();
config.orchestrator = cfg;
self.config = Arc::new(config);
self.orchestrator_persona = persona.name.clone();
self.command_result = format!("persona set to {} (takes effect on start)", name);
return;
};
let tx = h.tx.clone();
let boxed = Box::new(cfg.clone());
let mut config = (*self.config).clone();
config.orchestrator = cfg;
self.config = Arc::new(config);
self.orchestrator_persona = persona.name.clone();
tokio::spawn(async move {
let _ = tx
.send(crate::orchestrator::OrchestratorMsg::SetConfig(boxed))
.await;
});
self.chat_system(format!("persona → {}", name));
self.command_result = format!("persona set to {}", name);
}

/// Break the orchestrator out of its current turn (/interrupt, /stop).
/// The turn stops at the next safe point: immediately if it's blocked in
/// a tool call, otherwise before the next tool iteration.
Expand Down
151 changes: 151 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub struct Config {
pub orchestrator: OrchestratorConfig,
pub chat: ChatConfig,
pub profiles: Vec<Profile>,
/// User-defined personas; a name matching a builtin replaces it.
#[serde(default)]
pub personas: Vec<Persona>,
}

// ── [orchestrator] ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -112,6 +115,20 @@ pub struct OrchestratorConfig {
/// Lines of session output inlined into a [linkshell event]
/// notification. The orchestrator can always read_output for more.
pub event_tail_lines: usize,
/// Tool names the orchestrator may call. Empty means the full set. This
/// is the mechanical half of a persona: a system-prompt instruction to be
/// cautious is a suggestion to a small local model, whereas omitting
/// `send_input` from the schema is a guarantee.
pub allowed_tools: Vec<String>,
/// Extra text appended to the system prompt by the active persona.
pub persona_note: String,
/// Name of the active persona (informational; shown in the status row).
pub persona: String,
/// Seconds during which an identical (tool, arguments) call is answered
/// with a duplicate_call error instead of being re-executed. Bounds the
/// cross-turn loops that max_tool_iterations (per-turn) cannot see.
/// 0 disables.
pub tool_dedup_secs: u64,
/// Cap on lines returned by send_input wait_ready / `input --wait`.
/// Longer replies are truncated to the last N lines with a marker.
/// 0 disables.
Expand Down Expand Up @@ -156,6 +173,10 @@ impl Default for OrchestratorConfig {
max_context_tokens: 60_000,
tool_result_keep_turns: 3,
event_tail_lines: 5,
allowed_tools: Vec::new(),
persona_note: String::new(),
persona: String::new(),
tool_dedup_secs: 45,
wait_ready_max_lines: 80,
}
}
Expand Down Expand Up @@ -400,6 +421,136 @@ impl Default for NotificationsConfig {
}
}

/// A named behavioural preset layered over `[orchestrator]`.
///
/// Personas modulate *autonomy and eagerness*, not correctness: the loop
/// suppressor, the elision stubs and the send_input evidence are
/// unconditional. Every field is optional, and `None` means "inherit from
/// `[orchestrator]`" — an explicit setting there still wins unless the
/// persona overrides it.
///
/// [[personas]]
/// name = "assistant"
/// events = []
/// approval = "propose"
/// allowed_tools = ["list_sessions", "read_output", "use_skill", "remember"]
/// max_tool_iterations = 4
/// tool_dedup_secs = 300
/// note = "You observe and advise. You do not drive sessions."
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default)]
#[serde(default)]
pub struct Persona {
pub name: String,
pub events: Option<Vec<String>>,
pub event_cooldown_secs: Option<u64>,
pub approval: Option<String>,
pub auto_approve: Option<Vec<String>>,
pub allowed_tools: Option<Vec<String>>,
pub max_tool_iterations: Option<usize>,
pub tool_dedup_secs: Option<u64>,
pub max_context_tokens: Option<usize>,
pub event_tail_lines: Option<usize>,
/// Appended to the system prompt.
pub note: String,
}

impl Persona {
/// Layer this persona over a base orchestrator config.
pub fn apply(&self, base: &OrchestratorConfig) -> OrchestratorConfig {
let mut cfg = base.clone();
if let Some(v) = &self.events {
cfg.events = v.clone();
}
if let Some(v) = self.event_cooldown_secs {
cfg.event_cooldown_secs = v;
}
if let Some(v) = &self.approval {
cfg.approval = v.clone();
}
if let Some(v) = &self.auto_approve {
cfg.auto_approve = v.clone();
}
if let Some(v) = &self.allowed_tools {
cfg.allowed_tools = v.clone();
}
if let Some(v) = self.max_tool_iterations {
cfg.max_tool_iterations = v;
}
if let Some(v) = self.tool_dedup_secs {
cfg.tool_dedup_secs = v;
}
if let Some(v) = self.max_context_tokens {
cfg.max_context_tokens = v;
}
if let Some(v) = self.event_tail_lines {
cfg.event_tail_lines = v;
}
cfg.persona_note = self.note.clone();
cfg.persona = self.name.clone();
cfg
}
}

/// The three shipped personas, used when no `[[personas]]` entry matches.
/// Ordered by autonomy: assistant looks, monitor reports, orchestrator acts.
pub fn builtin_personas() -> Vec<Persona> {
let read_only = vec![
"list_sessions".to_string(),
"read_output".to_string(),
"use_skill".to_string(),
"remember".to_string(),
];
vec![
Persona {
name: "assistant".into(),
events: Some(Vec::new()),
approval: Some("propose".into()),
allowed_tools: Some(read_only.clone()),
max_tool_iterations: Some(4),
tool_dedup_secs: Some(300),
note: "You are a reactive assistant. You answer when spoken to. You can \
inspect sessions but cannot drive them; if something needs doing, \
say so and let the user do it."
.into(),
..Default::default()
},
Persona {
name: "monitor".into(),
events: Some(vec!["waiting".into(), "error".into(), "dead".into()]),
event_cooldown_secs: Some(60),
approval: Some("propose".into()),
allowed_tools: None, // full set, but writes are gated by propose
auto_approve: Some(read_only),
max_tool_iterations: Some(8),
tool_dedup_secs: Some(120),
note: "You watch sessions and report. Investigate freely with read-only \
tools; anything that changes a session is proposed for approval \
first. Prefer one clear report over a stream of updates."
.into(),
..Default::default()
},
Persona {
name: "orchestrator".into(),
events: Some(vec![
"ready".into(),
"waiting".into(),
"error".into(),
"dead".into(),
]),
event_cooldown_secs: Some(15),
approval: Some("auto".into()),
max_tool_iterations: Some(12),
tool_dedup_secs: Some(45),
note: "You actively route work between sessions. Act without asking for \
routine steps. Before repeating an action, check whether the \
previous one had an effect; if you cannot tell, say so rather \
than trying again."
.into(),
..Default::default()
},
]
}

#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
pub struct Profile {
pub name: String,
Expand Down
Loading
Loading