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
81 changes: 77 additions & 4 deletions crates/ai-host/src/contained.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>" 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.
Expand Down Expand Up @@ -96,6 +106,61 @@ pub fn declared(app_id: &str) -> Result<BTreeSet<String>, 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<Option<Arc<dyn PeerFactory>>> = Mutex::new(None);

/// The factory [`prepare`] launches peers with (the shell's, at startup).
pub fn set_factory(factory: Arc<dyn PeerFactory>) {
*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 <app>" 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<String> {
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<Arc<dyn OctosAppService>, 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 <app>" 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<Arc<dyn OctosContext>, 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<Option<HashMap<String, Arc<dyn OctosAppService>>>> = Mutex::new(None);
Expand All @@ -104,6 +169,13 @@ fn live<R>(f: impl FnOnce(&mut HashMap<String, Arc<dyn OctosAppService>>) -> 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.
Expand Down Expand Up @@ -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");
Expand All @@ -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)
Expand Down
65 changes: 61 additions & 4 deletions crates/ai-host/src/contained/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -204,7 +211,9 @@ fn ask(app: &str, service: &str, args: Value, from_sheet: bool) -> Result<Value,
}

fn serial() -> 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]
Expand Down Expand Up @@ -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::<BTreeSet<_>>(), "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::<BTreeSet<_>>(), "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();
Expand All @@ -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::<BTreeSet<_>>());
// 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);
}
12 changes: 10 additions & 2 deletions crates/ai-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<app id>`)
//! 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 <app>" 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
Expand Down Expand Up @@ -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;
}
Expand Down
18 changes: 18 additions & 0 deletions crates/app-peers/src/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,15 @@ pub fn interrupt_where(matches: impl Fn(&str) -> bool) -> Vec<String> {
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 <app>" panel).
pub fn live(app_id: &str) -> Option<Broker> {
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 {
Expand Down Expand Up @@ -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<String> {
self.0.lock().account.clone()
}

/// The bound peer's slug and session, once bound.
pub fn peer(&self) -> Option<(String, String)> {
self.0
Expand Down Expand Up @@ -2789,6 +2803,10 @@ impl OctosAppService for Broker {
self.0.cfg.services.clone()
}

fn prepare(&self) -> Result<(), String> {
self.bind()
}

fn model(&self) -> Option<ModelInfo> {
self.0.lock().model.clone()
}
Expand Down
9 changes: 9 additions & 0 deletions crates/app-peers/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,15 @@ pub trait OctosAppService: Send + Sync {
fn open_conversation(&self, spec: ContextSpec) -> Result<Arc<dyn OctosContext>, 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);
Expand Down
Loading
Loading