From 9a59d7dbd7c74defb3a329d7d81875fb63b63b5f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:39:21 -0700 Subject: [PATCH] An agent for every app that declares one, and a shell-drawn "Ask " ADR 0004 section 4. A live run found News (tools.json, no octos.*) with no peer: the system agent's peer_list was empty, News's tools never registered, Settings listed only Rinx, and the person had nowhere to talk to an app's agent. - Which apps: a native app granted agent.octos; a script app whose manifest declares octos.* or an agent block, or whose admitted bundle ships tools.json (App Hub's AgentBundle::load). Settings lists all. - Peers once allowed: when the person allows an agent (first-use sheet, Settings) and at startup, the shell prepares its peer (contained::prepare -> OctosAppService::prepare -> peer/prepare, tools registered), so peer_list shows it. One peer per app, shared by the app, its cards, the panel and the system agent; the app's own context still holds only the octos.* services its manifest declares. - The system agent is told about agents it cannot list: a note with the system chat's turn when the apps' agents changed, and host tools agents.list / agents.ask (the latter shows the first-use sheet). - "Ask ": the system chat's pane in app mode (app_chat/): a sharing context via open_conversation, both lanes merged with speakers, a composer, Stop (both lanes), the app's questions; from the bar's "Ask ", Shift+F8 or Setup > Assistant; beside the system chat on a desktop, full screen on a phone. - Revocation releases the peer and closes the panel's context. Tests: News is listed; the panel opens a sharing context and sends person turns there; allowed agents are prepared (fake peer); and on a real kernel: prepared with News's tools, listed by the system agent's peer_list, the panel's person turn seen by the system agent's peer/input turn, revoked. Docs: ADR 0004 section 4 and a follow-up; architecture. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ai-host/src/contained.rs | 81 ++- crates/ai-host/src/contained/tests.rs | 65 ++- crates/ai-host/src/lib.rs | 12 +- crates/app-peers/src/broker.rs | 18 + crates/app-peers/src/contract.rs | 9 + crates/shell/src/agents.rs | 370 +++++++++++++ crates/shell/src/app_chat/mod.rs | 493 ++++++++++++++++++ crates/shell/src/app_chat/model.rs | 209 ++++++++ crates/shell/src/app_chat/tests.rs | 358 +++++++++++++ crates/shell/src/approvals/consent.rs | 13 +- crates/shell/src/approvals/mod.rs | 5 + crates/shell/src/approvals/tests.rs | 2 +- crates/shell/src/apps.rs | 40 +- .../shell/src/host_tools/real_kernel_tests.rs | 167 ++++++ crates/shell/src/lib.rs | 83 ++- crates/shell/src/questions/tests.rs | 2 +- crates/shell/src/shell/bar.rs | 17 + crates/shell/src/shell/menu.rs | 9 + crates/shell/src/system_chat/mod.rs | 42 +- crates/shell/src/system_chat/model.rs | 19 +- crates/shell/src/system_chat/session.rs | 31 +- crates/shell/src/system_chat/view.rs | 168 +++++- .../adr/0004-native-apps-hosting-and-peers.md | 10 +- docs/architecture.md | 7 +- docs/architecture.zh-CN.md | 4 +- 25 files changed, 2166 insertions(+), 68 deletions(-) create mode 100644 crates/shell/src/agents.rs create mode 100644 crates/shell/src/app_chat/mod.rs create mode 100644 crates/shell/src/app_chat/model.rs create mode 100644 crates/shell/src/app_chat/tests.rs diff --git a/crates/ai-host/src/contained.rs b/crates/ai-host/src/contained.rs index 7d5b0305..e14d7a91 100644 --- a/crates/ai-host/src/contained.rs +++ b/crates/ai-host/src/contained.rs @@ -30,6 +30,16 @@ //! the `octos.*` services its manifest declares ([`declared`]), and turning //! its agent off in Settings releases the live peer at once ([`revoke`]). //! +//! **An agent for every app that declares one** (ADR 0004 §4). A script +//! app's peer does not wait for the app to call `octos`: once the person +//! allowed its agent, the shell prepares it ([`prepare`]: `peer/prepare`, +//! its tools registered), so the system agent's `peer_list` shows it and +//! `peer_send_input` reaches it, and the shell's "Ask " panel opens +//! the app's conversation on it ([`conversation`]). The app's own `octos` +//! calls use the same peer, still gated by the services its manifest +//! declares; an app that declares none (News: it ships `tools.json` only) +//! never calls it, and has its agent all the same. +//! //! The logic does not need a kernel: peers come from a [`PeerFactory`]. The //! shell's factory (`cfg(kernel)`) launches them through //! `octosense_app_peers::hosted`; tests use a fake one. @@ -96,6 +106,61 @@ pub fn declared(app_id: &str) -> Result, String> { Ok(services) } +/// Where the shell's own preparations get peers ([`prepare`]): the factory +/// the registered service uses (`register_contained`), or a test's. +static FACTORY: Mutex>> = Mutex::new(None); + +/// The factory [`prepare`] launches peers with (the shell's, at startup). +pub fn set_factory(factory: Arc) { + *FACTORY.lock().unwrap_or_else(|e| e.into_inner()) = Some(factory); +} + +/// The services a peer is launched with: all of them, for the shell's own +/// surfaces (the "Ask " panel reads history, starts turns and stops +/// them). What the APP may call is still only what its manifest declares, +/// twice over: [`ContainedOctos`] checks [`declared`] before any call +/// reaches the peer, and the app's own context is opened with exactly +/// those services (the broker checks every call against its context's). +fn peer_services() -> BTreeSet { + octosense_app_peers::OCTOS_SERVICES.iter().map(|s| s.to_string()).collect() +} + +/// `app_id`'s live peer, or a new one from `factory` (kept live, so every +/// caller shares ONE peer per app). +fn obtain(app_id: &str, factory: &dyn PeerFactory) -> Result, String> { + if let Some(service) = live(|l| l.get(app_id).cloned()) { + return Ok(service); + } + let peer = peer_id(app_id)?; + let service = factory.launch(&peer, app_id, &peer_services()).ok_or(UNAVAILABLE)?; + service.set_account(Some(ACCOUNT)); + Ok(live(|l| l.entry(app_id.to_owned()).or_insert(service).clone())) +} + +/// The shell prepares `app_id`'s agent (the person allowed it): its peer is +/// created or resumed and its tools registered now, without a turn. Blocks +/// (at most a minute): call it off the UI thread. Consent is the caller's +/// check (the shell asks `approvals::consent_granted` first). +pub fn prepare(app_id: &str) -> Result<(), String> { + let factory = FACTORY.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or(UNAVAILABLE)?; + let service = obtain(app_id, factory.as_ref())?; + service.prepare() +} + +/// Whether `app_id` has a live peer (prepared, or opened by the app). +pub fn is_live(app_id: &str) -> bool { + live(|l| l.contains_key(app_id)) +} + +/// The app's conversation for a shell surface (the "Ask " panel, the +/// person's lane): a new sharing context on the app's one peer, created if +/// needed. Consent is the caller's check. +pub fn conversation(app_id: &str, instance: &str) -> Result, String> { + let factory = FACTORY.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or(UNAVAILABLE)?; + let service = obtain(app_id, factory.as_ref())?; + service.open_conversation(ContextSpec { account: ACCOUNT.to_owned(), instance: instance.to_owned(), services: service.services() }) +} + /// Every contained app's live peer, by app id, so turning its agent off /// revokes it at once ([`revoke`]). static LIVE: Mutex>>> = Mutex::new(None); @@ -104,6 +169,13 @@ fn live(f: impl FnOnce(&mut HashMap>) -> R) f(LIVE.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new)) } +/// Tests: forget every live peer and the factory (the maps are global). +#[cfg(test)] +pub(crate) fn reset_for_tests() { + live(|l| l.clear()); + *FACTORY.lock().unwrap_or_else(|e| e.into_inner()) = None; +} + /// The person turned `app_id`'s agent off (Settings): its peer is released /// now, closing its contexts and any running turn. A later call needs /// consent again and then gets a fresh peer. True when one was live. @@ -208,10 +280,9 @@ impl ContainedOctos { self.apps.remove(app_id); } if !self.apps.contains_key(app_id) { + // The peer the shell prepared, if it did: one peer per app. let peer = peer_id(app_id)?; - let service = self.factory.launch(&peer, app_id, services).ok_or(UNAVAILABLE)?; - service.set_account(Some(ACCOUNT)); - live(|l| l.insert(app_id.to_owned(), service.clone())); + let service = obtain(app_id, self.factory.as_ref())?; self.apps.insert(app_id.to_owned(), AppPeer { peer, service, context: None, generation: 0 }); } let app = self.apps.get_mut(app_id).expect("inserted above"); @@ -222,7 +293,9 @@ impl ContainedOctos { let context = app.service.open_conversation(ContextSpec { account: ACCOUNT.to_owned(), instance: format!("{}-g{}", app.peer, app.generation), - services: app.service.services(), + // The app's own handle: only what its manifest declares, even + // on a peer the shell launched with every service for its panel. + services: services.clone(), })?; app.context = Some(context.clone()); Ok(context) diff --git a/crates/ai-host/src/contained/tests.rs b/crates/ai-host/src/contained/tests.rs index da05e8b5..6465b9db 100644 --- a/crates/ai-host/src/contained/tests.rs +++ b/crates/ai-host/src/contained/tests.rs @@ -88,6 +88,8 @@ struct FakeService { /// How many handles were the app's conversation (not a request context). conversations: AtomicUsize, released: AtomicBool, + /// How many times the shell prepared it. + prepared: AtomicUsize, } impl OctosAppService for FakeService { @@ -124,6 +126,10 @@ impl OctosAppService for FakeService { self.conversations.fetch_add(1, Ordering::SeqCst); self.open_context(spec) } + fn prepare(&self) -> Result<(), String> { + self.prepared.fetch_add(1, Ordering::SeqCst); + Ok(()) + } fn release(&self) { self.released.store(true, Ordering::SeqCst); for c in self.contexts.lock().unwrap().iter() { @@ -172,6 +178,7 @@ impl PeerFactory for Peers { contexts: Mutex::default(), conversations: AtomicUsize::new(0), released: AtomicBool::new(false), + prepared: AtomicUsize::new(0), }); self.launched.lock().unwrap().push((peer_id.to_owned(), service.clone())); Some(service) @@ -204,7 +211,9 @@ fn ask(app: &str, service: &str, args: Value, from_sheet: bool) -> Result std::sync::MutexGuard<'static, ()> { - SERIAL.lock().unwrap_or_else(|e| e.into_inner()) + let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_tests(); + guard } #[test] @@ -428,9 +437,12 @@ fn contained_apps_get_only_the_octos_services_their_manifest_declares() { let peers = Peers::new(Turn::Reply(json!({"turn_id": "t1", "text": "ok"}))); register(true, &peers); ask("com.example.reader", "octos.session.open", json!({}), false).expect("declared"); - let granted = peers.granted.lock().unwrap().clone(); - assert_eq!(granted.len(), 1); - assert_eq!(granted[0].1, ["octos.session.history", "octos.session.open"].iter().map(|s| s.to_string()).collect::>(), "never all four"); + // The peer serves the shell's panel too; the app's own context holds + // only what its manifest declares, never all four. + assert_eq!(peers.granted.lock().unwrap().len(), 1); + let specs = peers.service("card.com.example.reader").specs.lock().unwrap().clone(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].services, ["octos.session.history", "octos.session.open"].iter().map(|s| s.to_string()).collect::>(), "never all four"); let err = ask("com.example.reader", "octos.turn.start", json!({"text": "hi"}), false).unwrap_err(); assert_eq!(err, NOT_DECLARED); let err = ask("com.example.unknown", "octos.session.open", json!({}), false).unwrap_err(); @@ -453,3 +465,48 @@ fn turning_an_agent_off_releases_its_live_peer_at_once() { ask(APP, "octos.turn.start", json!({"text": "again"}), false).expect("a reply"); assert_eq!(peers.ids().len(), 2); } + +/// ADR 0004 §4, an agent for every app that declares one: the shell +/// prepares a consented app's peer without the app calling `octos` (News +/// ships tools.json and declares no `octos.*`); the app's own calls, the +/// shell's panel and the preparation share ONE peer; the panel's +/// conversation holds every service while the app's own context holds only +/// what it declares; revoking releases the peer and a later preparation +/// gets a fresh one. +#[test] +fn the_shell_prepares_a_consented_apps_peer_and_its_panel_shares_it() { + let _g = serial(); + set_declared(manifests); + let peers = Peers::new(Turn::Reply(json!({"turn_id": "t1", "text": "ok"}))); + register(true, &peers); + assert_eq!(prepare("os.news"), Err(UNAVAILABLE.to_string()), "no factory: nothing to prepare with"); + set_factory(peers.clone()); + prepare("os.news").expect("prepared"); + assert!(is_live("os.news")); + assert_eq!(peers.ids(), vec!["card.os.news".to_string()]); + let news = peers.service("card.os.news"); + assert_eq!(news.prepared.load(Ordering::SeqCst), 1, "bound now, before any turn"); + assert_eq!(news.accounts.lock().unwrap().as_slice(), [Some(ACCOUNT.to_string())]); + // Preparing again is the same peer. + prepare("os.news").unwrap(); + assert_eq!(peers.ids().len(), 1); + // The shell's panel: a conversation on the same peer, every service. + let panel = conversation("os.news", "shell-ask-1").expect("the person's lane"); + assert!(panel.is_open()); + assert_eq!(news.conversations.load(Ordering::SeqCst), 1); + assert_eq!(news.specs.lock().unwrap()[0].services.len(), OCTOS_SERVICES.len()); + // An app that also calls `octos` itself uses that peer, with only what + // it declares. + prepare("com.example.reader").unwrap(); + ask("com.example.reader", "octos.session.open", json!({}), false).expect("declared"); + assert_eq!(peers.ids(), vec!["card.os.news".to_string(), "card.com.example.reader".to_string()], "no second peer"); + let reader = peers.service("card.com.example.reader").specs.lock().unwrap().clone(); + assert_eq!(reader[0].services, ["octos.session.history", "octos.session.open"].iter().map(|s| s.to_string()).collect::>()); + // Turned off: released at once; allowed again, a fresh peer. + assert!(revoke("os.news")); + assert!(news.released.load(Ordering::SeqCst)); + assert!(!panel.is_open(), "the panel's conversation closed with it"); + assert!(!is_live("os.news")); + prepare("os.news").unwrap(); + assert_eq!(peers.ids().iter().filter(|id| *id == "card.os.news").count(), 2); +} diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index cc1ad5cb..56a1f336 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -43,8 +43,11 @@ //! interrupts its peer's running work; the kernel and other apps go on. //! - **The `octos` host service** ([`contained`]): contained apps the Card //! runner hosts that declare `octos.*` get their own peer (`card.`) -//! under the same contract, while [`Policy::contained_apps`] is on. Tool -//! approvals are declined until the Card runner has an approval sheet. +//! under the same contract, while [`Policy::contained_apps`] is on. The +//! shell also prepares that peer for every app whose agent the person +//! allowed, whether or not the app calls `octos` (`contained::prepare`), +//! and opens the app's conversation on it for its "Ask " panel +//! (`contained::conversation`). Approvals go to the shell's router. //! - **The system toolbox for app agents** (feature `toolbox-peers`, off by //! default): the toolbox's grants and the executor the shell's host-tool //! relay runs its calls on ([`toolbox_peers`]). Its results live in each @@ -343,6 +346,11 @@ fn register_contained(kernel: bool, policy: &Policy) { gate, std::sync::Arc::new(contained::KernelPeers), ))); + // The shell prepares consented apps' agents on the same peers + // (`contained::prepare`), unless contained apps are off. + if gate != ContainedGate::Off { + contained::set_factory(std::sync::Arc::new(contained::KernelPeers)); + } log!("octos: contained apps' service registered ({gate:?})"); return; } diff --git a/crates/app-peers/src/broker.rs b/crates/app-peers/src/broker.rs index 664005d6..a06ba181 100644 --- a/crates/app-peers/src/broker.rs +++ b/crates/app-peers/src/broker.rs @@ -387,6 +387,15 @@ pub fn interrupt_where(matches: impl Fn(&str) -> bool) -> Vec { brokers.into_iter().filter(|b| matches(&b.cfg.app_id)).flat_map(|b| Broker(b).interrupt_running()).collect() } +/// The live (not released) broker of the app whose id is `app_id`, if one +/// runs: a shell surface opens the app's conversation on the same peer the +/// app uses (the shell's "Ask " panel). +pub fn live(app_id: &str) -> Option { + let mut all = BROKERS.lock().unwrap_or_else(|e| e.into_inner()); + all.retain(|b| b.strong_count() > 0); + all.iter().rev().filter_map(Weak::upgrade).find(|b| b.cfg.app_id == app_id && !b.lock().released).map(Broker) +} + /// Whether a kernel refusal is `turn_in_progress` (the session runs another /// turn: the kernel queues nothing). fn turn_in_progress(error: &str) -> bool { @@ -588,6 +597,11 @@ impl Broker { self.0.lock().generation } + /// The account the broker acts for now (`None`: signed out). + pub fn account(&self) -> Option { + self.0.lock().account.clone() + } + /// The bound peer's slug and session, once bound. pub fn peer(&self) -> Option<(String, String)> { self.0 @@ -2789,6 +2803,10 @@ impl OctosAppService for Broker { self.0.cfg.services.clone() } + fn prepare(&self) -> Result<(), String> { + self.bind() + } + fn model(&self) -> Option { self.0.lock().model.clone() } diff --git a/crates/app-peers/src/contract.rs b/crates/app-peers/src/contract.rs index aeae49a9..aae35e8d 100644 --- a/crates/app-peers/src/contract.rs +++ b/crates/app-peers/src/contract.rs @@ -305,6 +305,15 @@ pub trait OctosAppService: Send + Sync { fn open_conversation(&self, spec: ContextSpec) -> Result, String> { self.open_context(spec) } + /// Create or resume the app's peer now, without a turn: `peer/prepare`, + /// its tools registered, its session open, so the system agent's + /// `peer_list` shows it and `peer_send_input` reaches it (ADR 0004 §4: + /// the shell prepares a consented app's agent). Blocks until done (at + /// most a minute); call it off the UI thread. A service without a peer + /// of its own has nothing to prepare. + fn prepare(&self) -> Result<(), String> { + Ok(()) + } /// The app is closing: close every context and release subscriptions. /// Does not stop a shared kernel or other apps' work. fn release(&self); diff --git a/crates/shell/src/agents.rs b/crates/shell/src/agents.rs new file mode 100644 index 00000000..084f8339 --- /dev/null +++ b/crates/shell/src/agents.rs @@ -0,0 +1,370 @@ +//! An agent for every app that declares one (ADR 0004 §4, §6). +//! +//! **Which apps.** [`crate::apps::agent_apps`]: a native app whose +//! `native-apps.json` entry grants `octos.*`; a script app whose manifest +//! declares `octos.*` or an `agent` block, or whose admitted bundle ships +//! `tools.json` (App Hub's `AgentBundle::load`). From manifests and grants, +//! never names. Settings › Assistant lists every one of them. +//! +//! **Peers exist once allowed.** The person allows an app's agent on the +//! first-use sheet (from the app's "Ask " panel, the app's own `octos` +//! call, or the system agent's `agents.ask`) or in Settings. From then on +//! the shell PREPARES its peer ([`prepare`]: `peer/prepare`, its tools +//! registered, its session open), at startup for the apps already allowed +//! and at the moment one is allowed, so the system agent's `peer_list` +//! shows it and `peer_send_input` reaches it. A script app's peer is the +//! contained `octos` service's (`card.`), shared with the app's own +//! calls and the panel. A native app's peer is its instance's (the module +//! host offers it at `create`), so it exists while the app is open; a native +//! app that keeps accounts cannot be prepared without its signed-in account. +//! Turning an agent off releases its peer (`contained::revoke`, lib.rs +//! `revoke_agents`); the shell refuses the system agent's input to it +//! (`host_tools::ShellToolHost::admit_input`). +//! +//! **What the system agent is told.** octos lists only prepared peers, and +//! has no hook for "this app has an agent you may not use yet". The shell +//! says it in two places of its own: a short note with the system chat's +//! turns whenever the apps' agents changed ([`system_note`]), and two host +//! tools on the system session, `agents.list` and `agents.ask` +//! ([`declarations`], answered by the system chat itself: [`call`]). +//! `agents.ask` shows the first-use sheet (over the system chat); only the +//! person answers it. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +use crate::ai_host::app_peers::host_tools::ToolOutcome; +use crate::ai_host::app_peers::OctosContext; +use crate::apps::AgentApp; + +/// Tests that install the contained service's peer factory (it is global) +/// hold this. +#[cfg(test)] +pub(crate) static FACTORY_TESTS: Mutex<()> = Mutex::new(()); + +/// The host tools the system chat registers on the system session. +pub const LIST_TOOL: &str = "agents.list"; +pub const ASK_TOOL: &str = "agents.ask"; +/// Their owner, as the kernel shows it. +pub const OWNER: &str = "agents"; + +/// Where one app's agent stands for the person. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + /// The person has not decided (the first use asks). + NotAsked, + Allowed, + /// Turned off (Settings, or denied on the first-use sheet). + Off, +} + +impl Access { + pub fn as_str(self) -> &'static str { + match self { + Access::NotAsked => "not yet allowed", + Access::Allowed => "allowed", + Access::Off => "off", + } + } +} + +/// `app`'s access: developer mode grants every agent (ADR 0004 §13). +pub fn access(app: &str) -> Access { + if crate::approvals::consent_granted(app) { + return Access::Allowed; + } + match crate::approvals::with(|a| a.consent.state(app)) { + Some(crate::approvals::consent::State::Denied) => Access::Off, + _ => Access::NotAsked, + } +} + +/// The peer id of an app's agent: a native app's id, a script app's +/// `card.`. +pub fn peer_of(app: &AgentApp) -> String { + if app.native { + app.id.clone() + } else { + format!("{}{}", crate::ai_host::contained::PEER_PREFIX, app.id) + } +} + +/// Every app with an agent. +pub fn all() -> Vec { + crate::apps::agent_apps() +} + +/// The app with an agent that `name` means: its id, its peer id, its +/// display name (any case), or a launcher id (`news` for `os.news`, +/// `hub:` for an installed app). +pub fn find(name: &str) -> Option { + find_in(&all(), name) +} + +pub fn find_in(apps: &[AgentApp], name: &str) -> Option { + let name = name.trim(); + let bare = name.strip_prefix("hub:").unwrap_or(name); + apps.iter() + .find(|a| a.id == bare || peer_of(a) == bare || a.name.eq_ignore_ascii_case(bare) || a.id == format!("os.{bare}")) + .cloned() +} + +// ------------------------------------------------------------ peers + +/// What the shell knows of each preparation: running, done, or why not. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Prepared { + Preparing, + Ready, + Failed(String), +} + +static PREPARED: Mutex> = Mutex::new(BTreeMap::new()); + +fn set_prepared(app: &str, state: Option) { + let mut map = PREPARED.lock().unwrap_or_else(|e| e.into_inner()); + match state { + Some(state) => map.insert(app.to_string(), state), + None => map.remove(app), + }; + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); +} + +/// Where `app`'s preparation stands (`None`: never tried in this run). +pub fn prepared(app: &str) -> Option { + PREPARED.lock().unwrap_or_else(|e| e.into_inner()).get(app).cloned() +} + +/// Prepare `app`'s agent now, off the UI thread, if the person allowed it +/// and it is not prepared or preparing. A native app's peer is its open +/// instance's: nothing to prepare here. +pub fn prepare(app: &AgentApp) { + if app.native || access(&app.id) != Access::Allowed { + return; + } + if matches!(prepared(&app.id), Some(Prepared::Preparing)) { + return; + } + if matches!(prepared(&app.id), Some(Prepared::Ready)) && crate::ai_host::contained::is_live(&app.id) { + return; + } + set_prepared(&app.id, Some(Prepared::Preparing)); + let id = app.id.clone(); + let spawned = std::thread::Builder::new().name(format!("prepare-{id}")).spawn(move || { + let outcome = crate::ai_host::contained::prepare(&id); + match &outcome { + Ok(()) => makepad_widgets::log!("agents: {id}'s agent is prepared (its peer is listed for the system agent)"), + Err(e) => makepad_widgets::log!("agents: {id}'s agent could not be prepared: {e}"), + } + // Turned off meanwhile: the revocation wins. + if access(&id) != Access::Allowed { + crate::ai_host::contained::revoke(&id); + set_prepared(&id, None); + return; + } + set_prepared(&id, Some(match outcome { + Ok(()) => Prepared::Ready, + Err(e) => Prepared::Failed(e), + })); + }); + if spawned.is_err() { + set_prepared(&app.id, Some(Prepared::Failed("no thread".into()))); + } +} + +/// Every allowed app's agent (startup, and when the assistant comes up). +pub fn prepare_allowed() { + for app in all() { + prepare(&app); + } +} + +/// At startup, once the kernel is configured: prepare the agents the person +/// already allowed. +pub fn start() { + let _ = std::thread::Builder::new().name("agents-start".into()).spawn(prepare_allowed); +} + +/// The apps the person just allowed (the first-use sheet, Settings) get +/// their peer now; the ones turned off are forgotten (lib.rs revokes their +/// services). +pub fn pump(revoked: &[String]) { + for app in revoked { + set_prepared(app, None); + } + let allowed = crate::approvals::take_allowed(); + if allowed.is_empty() { + return; + } + let apps = all(); + for id in allowed { + if let Some(app) = find_in(&apps, &id) { + prepare(&app); + } + } +} + +/// The app's conversation for a shell surface (the "Ask " panel): +/// the person's lane on the app's one peer. Only once the person allowed +/// the agent. A native app's is its open instance's peer. +pub fn conversation(app: &AgentApp, instance: &str) -> Result, String> { + if access(&app.id) != Access::Allowed { + return Err(format!("{}'s assistant is {}", app.name, access(&app.id).as_str())); + } + if !app.native { + let context = crate::ai_host::contained::conversation(&app.id, instance)?; + set_prepared(&app.id, Some(Prepared::Ready)); + return Ok(context); + } + native_conversation(app, instance) +} + +#[cfg(kernel)] +fn native_conversation(app: &AgentApp, instance: &str) -> Result, String> { + use crate::ai_host::app_peers::{ContextSpec, OctosAppService}; + let broker = crate::ai_host::app_peers::broker::live(&app.id).ok_or_else(|| format!("Open {} first: its assistant runs while it is open", app.name))?; + let account = broker.account().ok_or_else(|| format!("Sign in to {} to use its assistant", app.name))?; + let services = broker.services(); + broker.open_conversation(ContextSpec { account, instance: instance.to_string(), services }) +} + +#[cfg(not(kernel))] +fn native_conversation(app: &AgentApp, _instance: &str) -> Result, String> { + Err(format!("{}'s assistant is not available in this build", app.name)) +} + +/// Ask the person to allow `app`'s agent: the first-use sheet, once (only +/// the person answers it). The state it is in now. +pub fn ask(app: &AgentApp) -> Access { + if access(&app.id) == Access::Allowed { + return Access::Allowed; + } + let summary = crate::approvals::consent::AgentSummary::from_manifest(&app.id, &app.name, &app.manifest, &app.octos, "The model set in AI providers"); + match crate::approvals::consent_ask(summary) { + crate::approvals::consent::State::Allowed => Access::Allowed, + _ => access(&app.id), + } +} + +// ------------------------------------------------------------ the system agent + +/// One app's line for the system agent: its name, its peer and where it +/// stands, and what to do. +fn line(app: &AgentApp) -> Value { + let access = access(&app.id); + let peer = match (access, prepared(&app.id)) { + (Access::Allowed, Some(Prepared::Ready)) => "ready: in peer_list".to_string(), + (Access::Allowed, Some(Prepared::Preparing)) => "starting".to_string(), + (Access::Allowed, Some(Prepared::Failed(e))) => format!("could not start: {e}"), + (Access::Allowed, None) if app.native => format!("runs while {} is open", app.name), + (Access::Allowed, None) => "not started yet".to_string(), + _ => "none".to_string(), + }; + let what = match access { + Access::Allowed => format!("Reach it with peer_send_input (its peer is named \"{} …\" in peer_list).", peer_label(app)), + Access::NotAsked => format!("{}'s assistant is not yet allowed. Call agents.ask to ask the person, or tell them to open {} and use Ask {}.", app.name, app.name, app.name), + Access::Off => format!("{}'s assistant is off. Only the person can turn it on (Settings › Assistant › Approvals); tell them.", app.name), + }; + json!({"app": app.id, "name": app.name, "access": access.as_str(), "peer": peer, "what_to_do": what}) +} + +/// The name the kernel gives the app's peer (`