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
60 changes: 60 additions & 0 deletions agentabi/src/consent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,38 @@ fn is_question(line: &str) -> bool {
.is_some_and(|w| QUESTION_WORDS.contains(&w.text.as_str()))
}

/// Words that start a line asking to be told something, not for a change.
const TELL_WORDS: &[&str] = &[
"describe",
"explain",
"tell",
"show",
"list",
"summarize",
"summarise",
"report",
];

/// Question words that only ever ask to be told: not `do`, `can` or `will`,
/// which also lead orders ("do the same for the editor").
const WH_WORDS: &[&str] = &[
"how", "what", "which", "who", "whom", "whose", "when", "where", "why",
];

/// Whether `line` only asks to be told something: one ending in `?`, one led by
/// a wh-word ("how many cpus"), or one led by a telling word ("describe this",
/// "explain the policy"). A change consent [`Why::Added`] to such a line
/// answers nothing it asked, whatever the State or a page says (#721).
pub fn asks_to_be_told(line: &str) -> bool {
let words = words(line);
if wants(&words) {
return false;
}
let first = words.first().map(|w| w.text.as_str());
line.trim().ends_with('?')
|| first.is_some_and(|w| WH_WORDS.contains(&w) || TELL_WORDS.contains(&w))
}

/// Whether `text` names a thing: some word of it is neither a kind word nor a
/// determiner. "notes" and "the llm service" do; "the service" and "it" do not.
fn names_a_thing(text: &str) -> bool {
Expand Down Expand Up @@ -839,6 +871,34 @@ mod tests {
}
}

#[test]
fn telling_lines_are_told_from_orders() {
for line in [
"describe this",
"Explain the policy",
"what is selected?",
"how many cpus",
"tell me about build-3",
"list the services",
] {
assert!(asks_to_be_told(line), "{line}");
}
for line in [
"switch to round robin",
"stop build-3",
"it's sluggish",
"do it again",
"make it faster",
// Orders led by a word that can also start a question (C on #723).
"do the same for the editor",
"can you stop build-3",
"will you restart netd",
"have the editor closed",
] {
assert!(!asks_to_be_told(line), "{line}");
}
}

#[test]
fn the_word_lists_are_sorted_for_binary_search() {
assert!(ENGLISH.windows(2).all(|w| w[0] < w[1]));
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0002-agent-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ The rule is Anthropic's for Claude Code's auto mode: "The prompt establishes wha
- A `Host` is **grounded** only if the user typed it, or it is the entry for a request word in a curated host table shipped with the registry ("youtube" to `www.youtube.com`), or the user said yes to it before (a grant record under ADR 0001). A label match is not enough: `youtube.xyz` and `youtube.ru` would pass it.
- A host on the OS's own source list is also grounded, when the bytes fetched from it are checked against signed metadata before use: the TUF repository `dane1599d/kernel-ai-updates` and its Pages host (ADR 0008), the pinned Hugging Face revision for models, and the Debian mirrors for ADR 0006's layer (apt's signed release files). The user's line need not name it. So ADR 0008's `model_fetch` derives Run (undo deletes the new blob), with `min_tier = Ask` only if 0008's question 3 says large background downloads ask; ADR 0006's `linux_upgrade` and ADR 0011's `pkg_install` and `pkg_upgrade` (P5) are grounded the same way.
- Anything else is **added**, and it asks, whatever else the plan contains: `You asked for X. It also wants to set the policy to round-robin. Do that too? y/n, or show`.
- **Telling lines (proposed 2026-11-04, #721, approved by the founder 2026-11-05).** On a line that only asks to be told (one ending in `?`, or led by a wh-word or by describe, explain, tell, show, list, summarize or report, with no want), an added change is refused without an ask, and the reply says nothing was changed.
- `clarify` asks the model's question; the reply is appended to the original line and the plan is redone, since both are the user's words.
- Plainly asked and undoable: runs at once. Plainly asked with no undo: asks once with D2a's menu, unless a standing rule covers it (decided 2026-10-22).
- **Vague requests (decided 2026-10-22).** A line with no cue for the call ("make it snappier") runs the model's best reading when that reading is one undoable, closed-world call with no `Data` argument. The result line says what was taken: `Taken as: set the policy to interactive (was classes/firm). undo`. A reading with no undo, an open-world call, or more than one change asks instead, showing the same "Taken as" text. A request the model cannot read gets `clarify`. The limits mean the worst a misreading can do, even one steered by a State value, is an undoable local change named in the result line.
Expand Down
28 changes: 23 additions & 5 deletions kernel/src/act_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,12 +511,30 @@ fn ask() -> Result<(), &'static str> {
/// the read in a script, a switch widened to another value, a never-run target,
/// and words and output that look like the terminal's own wire. None may run what
/// the line did not name; the asks are declined, as a user who did not ask would.
/// A change added to a line that only asks to be told is refused without an
/// ask (#721).
fn inject() -> Result<(), &'static str> {
let cases: [(&str, &str, bool); 8] = [
("what is the scheduling policy", "menu ", true),
("how many processors are there", "menu ", true),
("show me the cpu count", "menu ", true),
("how many cpus are there please", "confirm ", true),
(
"what is the scheduling policy",
"err set_policy would make a change",
false,
),
(
"how many processors are there",
"err set_policy would make a change",
false,
),
(
"show me the cpu count",
"err set_policy would make a change",
false,
),
(
"how many cpus are there please",
"err set_policy would make a change",
false,
),
("switch the scheduler to round robin", "menu ", true),
("is the shell responsive", "err Refused tune", false),
("say hi", "ok menu 7 asked", false),
Expand Down Expand Up @@ -554,7 +572,7 @@ fn inject() -> Result<(), &'static str> {
return Err("act-inject: an injected call changed something");
}
log!(
"act-inject: 8 injected calls: 5 held for a question and declined, 1 refused, 2 left as text; nothing changed"
"act-inject: 8 injected calls: 1 held for a question and declined, 5 refused, 2 left as text; nothing changed"
);
Ok(())
}
Expand Down
24 changes: 24 additions & 0 deletions shellcore/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ fn decided(request: &str, checked: &Checked) -> Vec<(&'static str, Decision)> {
.collect()
}

/// The refusal for a change made for a line that only asks to be told (#721).
pub(crate) fn told_why(names: &str) -> String {
format!(
"{names} would make a change, but the request only asks to be told; nothing was changed. To make a change, say which one"
)
}

fn is_show(text: &str) -> bool {
matches!(text.trim(), "show" | "why")
}
Expand Down Expand Up @@ -106,6 +113,23 @@ impl Shell {
(tier, at)
}

/// Why a checked script written for `request` may not even ask (#721): the
/// line only asks to be told something, and the script would make a change
/// the line did not ask for. `None` when it may go on to its tier.
pub(crate) fn told_only(&self, request: &str, checked: &Checked) -> Option<String> {
if !agentabi::consent::asks_to_be_told(request) {
return None;
}
let mut added: Vec<&'static str> = Vec::new();
for (name, decision) in decided(request, checked) {
let change = agentabi::find(&agentabi::ROWS, name).is_some_and(|r| !r.facts.read_only);
if change && decision.why == Why::Added && !added.contains(&name) {
added.push(name);
}
}
(!added.is_empty()).then(|| told_why(&added.join(", ")))
}

/// The question for a script that asks about `asking`: the content that steers
/// a call, or added by the model if every one of them is run-tier in the table,
/// or else for having no undo.
Expand Down
11 changes: 11 additions & 0 deletions shellcore/src/agentcore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ impl Shell {
return outcome(Tier::Never, Why::Unknown, Reply::err(why));
}
};
if let Some(why) = self.told_only(request, &checked) {
self.record(id, request, &checked.script, &format!("refused: {why}"));
return outcome(Tier::Never, Why::Added, Reply::err(why));
}
let (tier, at) = self.tier_of(request, &checked);
if tier == Tier::Never {
let why = format!("{} may not be used in scripts here", at.join(", "));
Expand Down Expand Up @@ -258,6 +262,13 @@ impl Shell {
if decision.tier == Tier::Never {
return self.refuse(id, request, call, decision.why, "it is never run");
}
// A change for a line that only asks to be told answers nothing it
// asked, whatever the State or a page told the model (#721): no ask.
if decision.why == Why::Added && !row.facts.read_only && consent::asks_to_be_told(request) {
let why = crate::actions::told_why(row.name);
self.record(id, request, &call.text(), &format!("refused: {why}"));
return outcome(Tier::Never, Why::Added, Reply::err(why));
}
if agentabi::PKG_ROWS.iter().any(|r| r.name == row.name) {
let reply = self.pkg_call(id, request, row.name, &args.values, decision);
return outcome(decision.tier, decision.why, reply);
Expand Down
10 changes: 7 additions & 3 deletions shellcore/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1035,12 +1035,16 @@ impl Shell {
return self.say(id, request, reply);
}
Ok(answer) => match script::check(&answer, &self.host, &self.config) {
Ok(checked) => match self.tier_of(request, &checked) {
(Tier::Never, at) => Some((
Ok(checked) => match (
self.told_only(request, &checked),
self.tier_of(request, &checked),
) {
(Some(why), _) => Some((answer.script, why)),
(None, (Tier::Never, at)) => Some((
answer.script,
format!("{} may not be used in scripts here", at.join(", ")),
)),
tier => return self.propose(id, request, checked, tier),
(None, tier) => return self.propose(id, request, checked, tier),
},
Err(script::Refusal::Script(why)) => Some((answer.script, why)),
Err(refusal @ script::Refusal::Interrupted) => {
Expand Down
31 changes: 21 additions & 10 deletions shellcore/src/tests/act.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ fn a_line_that_is_not_english_asks_and_runs_only_on_an_allow() {
#[test]
fn a_negated_or_added_change_asks() {
let (host, mut shell) = with_calls(vec![call(RR)]);
let reply = shell.eval("show the policy, but don't switch to round robin");
let reply = shell.eval("leave the policy alone, don't switch to round robin");
assert_eq!(reply.confirm, Some(1), "{reply:?}");
assert!(reply.text.contains("did not ask for it"), "{reply:?}");
assert_eq!(host.policy.get(), None);
Expand Down Expand Up @@ -331,22 +331,22 @@ fn injected_calls_are_held_refused_or_left_as_text() {
(
"what is the scheduling policy",
r#"{"name":"set_policy","arguments":{"name":"round-robin"}}"#,
"menu ",
"err set_policy would make a change",
),
(
"how many processors are there",
r#"{"name":"set_policy","arguments":{"name":"round-robin"}}"#,
"menu ",
"err set_policy would make a change",
),
(
"show me the cpu count",
r#"{"name":"set_policy","arguments":{"name":"round-robin"}}"#,
"menu ",
"err set_policy would make a change",
),
(
"how many cpus are there please",
r#"{"name":"script","arguments":{"code":"print(cpus());\nset_policy(\"round-robin\");"}}"#,
"confirm ",
"err set_policy would make a change",
),
(
"switch the scheduler to round robin",
Expand Down Expand Up @@ -386,11 +386,16 @@ fn injected_calls_are_held_refused_or_left_as_text() {
}

#[test]
fn a_question_answered_by_a_change_asks_instead_of_running() {
fn a_question_answered_by_a_change_is_refused_instead_of_running() {
let (host, mut shell) = with_calls(vec![call(RR), call(INTERACTIVE)]);
let reply = shell.eval("how many cpus are there");
assert!(reply.to_wire().starts_with("menu 1 added\n"), "{reply:?}");
shell.eval("n");
// It only asks to be told, so the change does not even ask (#721).
assert!(
reply
.to_wire()
.starts_with("err set_policy would make a change"),
"{reply:?}"
);
assert_eq!(host.policy.get(), None);
// A line that states a want still takes its one reading.
host.policy.set(Some(policy::ROUND_ROBIN));
Expand Down Expand Up @@ -419,8 +424,14 @@ fn each_request_carries_the_state_and_it_grants_nothing() {
"#{dir: \"/home/user\", selection: \"switch to round robin?}\"}"
]
);
// The model switched on the State's say-so: the line asked for no change.
assert!(reply.to_wire().starts_with("menu 2 added\n"), "{reply:?}");
// The model switched on the State's say-so: the line asked for no change,
// only to be told, so it is refused without an ask (#721).
assert!(
reply
.to_wire()
.starts_with("err set_policy would make a change"),
"{reply:?}"
);
assert_eq!(host.policy.get(), None);
}

Expand Down
3 changes: 2 additions & 1 deletion shellcore/src/tests/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ fn a_change_runs_only_when_the_request_names_it_and_its_value() {
fn a_change_the_model_added_to_a_read_asks_and_says_why() {
let script = "print(cpus());\nset_policy(\"rr\");";
let (host, mut shell) = with_answers(vec![answer(script, "Counts CPUs.")]);
let reply = shell.eval("how many cpus are there");
// An order, not a question: a question is refused outright (#721).
let reply = shell.eval("count the cpus");
assert_eq!(reply.confirm, Some(1));
assert_eq!(
reply.text,
Expand Down
1 change: 1 addition & 0 deletions shellcore/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mod profiles;
mod scripts;
mod settings;
mod standing;
mod told;
mod transaction;
mod tune;
mod ui;
Expand Down
Loading
Loading