diff --git a/Cargo.lock b/Cargo.lock index ebd8e02b..d6bbf6cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7515,7 +7515,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=e8601b80ce104db2e48208094714bdcffdce6b5a#e8601b80ce104db2e48208094714bdcffdce6b5a" +source = "git+https://www.github.com/OctoSense-org/OctoSense-App-Hub?rev=8512be70b89b366e6d6ebf93e807188c4313c3c0#8512be70b89b366e6d6ebf93e807188c4313c3c0" dependencies = [ "base64 0.22.1", "blake3", @@ -7531,7 +7531,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=e8601b80ce104db2e48208094714bdcffdce6b5a#e8601b80ce104db2e48208094714bdcffdce6b5a" +source = "git+https://www.github.com/OctoSense-org/OctoSense-App-Hub?rev=8512be70b89b366e6d6ebf93e807188c4313c3c0#8512be70b89b366e6d6ebf93e807188c4313c3c0" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -7559,7 +7559,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=e8601b80ce104db2e48208094714bdcffdce6b5a#e8601b80ce104db2e48208094714bdcffdce6b5a" +source = "git+https://www.github.com/OctoSense-org/OctoSense-App-Hub?rev=8512be70b89b366e6d6ebf93e807188c4313c3c0#8512be70b89b366e6d6ebf93e807188c4313c3c0" dependencies = [ "blake3", "makepad-widgets", @@ -7580,7 +7580,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=e8601b80ce104db2e48208094714bdcffdce6b5a#e8601b80ce104db2e48208094714bdcffdce6b5a" +source = "git+https://www.github.com/OctoSense-org/OctoSense-App-Hub?rev=8512be70b89b366e6d6ebf93e807188c4313c3c0#8512be70b89b366e6d6ebf93e807188c4313c3c0" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 7e5b01df..52bf06a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "92a19b67218f8cd2361866c4554457b153766f48", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "92a19b67218f8cd2361866c4554457b153766f48" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } # END native-apps: pins # --- Makepad (OctoSense-org/makepad), the revision Octoscript-Makepad's # runtime.json pins (native-runtime.lock.json); resolved to .sources/makepad. @@ -93,12 +93,12 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "ecab2b985a6f60376683cf7317dc82579393558f" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "e8601b80ce104db2e48208094714bdcffdce6b5a" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } # --- octos: ONE source for every octos crate (octos-org/octos main at the # merge of octos#2626, the shared peer conversation with turn origin; after # #2621, `peer/input/reject`, and #2616, the #2567 follow-ups). @@ -243,6 +243,17 @@ octosense-app-peers = { path = "crates/app-peers" } octosense-kernel = { path = "crates/kernel" } octosense-llm-config = { path = "apps/ai-providers/config" } +# One App Hub: Rinx v1.0.0 names App Hub at an older rev (e8601b80) than the +# pin above (its mini-app catalog and system apps take app-policy and the +# hub client). A patch cannot point a source at another rev of itself, so +# every App Hub crate resolves through the www host alias of the same +# repository, at the pin. Drop this once Rinx pins the same App Hub commit. +[patch."https://github.com/OctoSense-org/OctoSense-App-Hub"] +octosense-app-hub = { git = "https://www.github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } +octosense-app-policy = { git = "https://www.github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } +octosense-appstore = { git = "https://www.github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } +octosense-app-hub-app = { git = "https://www.github.com/OctoSense-org/OctoSense-App-Hub", rev = "8512be70b89b366e6d6ebf93e807188c4313c3c0" } + # Rinx's Matrix stack: the patched ruma its own workspace uses (Rinx's # [patch] sections do not reach a consumer workspace). [patch."https://github.com/ruma/ruma"] diff --git a/apps/news/bundle/manifest.json b/apps/news/bundle/manifest.json index ed33a9f0..c3fc5819 100644 --- a/apps/news/bundle/manifest.json +++ b/apps/news/bundle/manifest.json @@ -1,4 +1,15 @@ { + "agent": { + "model": { + "needs": [ + "tool_calling" + ] + }, + "profile": "read-only", + "tools": [ + "ask_user_question" + ] + }, "capabilities": [ "storage", "net", diff --git a/crates/shell/src/app_chat/mod.rs b/crates/shell/src/app_chat/mod.rs index 2818ab30..d47c61e2 100644 --- a/crates/shell/src/app_chat/mod.rs +++ b/crates/shell/src/app_chat/mod.rs @@ -402,6 +402,19 @@ pub fn snapshot() -> ChatModel { model } +/// Tests that drive the kernel themselves (the two-lane scenario): the +/// panel is open for `app` without a conversation of its own, so it shows +/// the app's questions and answers them as the person would here. +#[cfg(test)] +pub(crate) fn show_for_tests(app: AgentApp) { + with(|p| { + p.app = Some(app); + p.open = true; + p.focused = true; + p.ui_generation += 1; + }); +} + pub fn status() -> Status { lock(&shared()).status.clone() } diff --git a/crates/shell/src/approvals/consent.rs b/crates/shell/src/approvals/consent.rs index a469febf..c7c9cd99 100644 --- a/crates/shell/src/approvals/consent.rs +++ b/crates/shell/src/approvals/consent.rs @@ -60,6 +60,11 @@ impl AgentSummary { if uses.is_empty() { uses.push(format!("{name}'s own tools")); } + // A script app's agent that keeps App Hub's one kernel tool for + // contained apps (`ask_user_question`), in the store's words. + if manifest["agent"]["tools"].as_array().is_some_and(|t| t.iter().any(|t| t == "ask_user_question")) { + uses.push("Ask you questions".to_string()); + } AgentSummary { app: app.into(), name: name.into(), reads, uses, model: model.into() } } } diff --git a/crates/shell/src/approvals/tests.rs b/crates/shell/src/approvals/tests.rs index 24b9ae00..bfdbd8a8 100644 --- a/crates/shell/src/approvals/tests.rs +++ b/crates/shell/src/approvals/tests.rs @@ -647,6 +647,17 @@ fn news() -> AgentSummary { ) } +/// A script app's agent that keeps `ask_user_question` says so on the +/// first-use sheet, in the store's words, beside its own tools. +#[test] +fn the_consent_sheet_says_a_script_agent_asks_questions() { + let manifest = json!({"capabilities": ["news"], "agent": {"profile": "read-only", "tools": ["ask_user_question"]}}); + let summary = AgentSummary::from_manifest("os.news", "News", &manifest, &[], "m"); + assert_eq!(summary.uses, ["News's own tools", "Ask you questions"]); + let quiet = AgentSummary::from_manifest("os.news", "News", &json!({"capabilities": ["news"]}), &[], "m"); + assert_eq!(quiet.uses, ["News's own tools"]); +} + /// The consent sheet and app storage read one `storage` block the same /// way: `accounts` defaults to false (one `device` folder) in both. #[test] diff --git a/crates/shell/src/host_tools/scenario_tests.rs b/crates/shell/src/host_tools/scenario_tests.rs index 2f6abfb2..44553c48 100644 --- a/crates/shell/src/host_tools/scenario_tests.rs +++ b/crates/shell/src/host_tools/scenario_tests.rs @@ -17,10 +17,10 @@ //! destructive and outward tools alike); both run on a //! recording `news` host service through the real script-app executor //! (`script_apps::HostServiceExecutor`), wrapped to count the calls it -//! runs. Its agent keeps octos's `ask_user_question`, granted through the -//! relay's `set_generic` seam (native apps' `agent.generic_tools`): App Hub -//! offers contained apps no octos kernel tool, so an admitted manifest -//! cannot ask for it. +//! runs. Its agent keeps octos's `ask_user_question`, from its manifest's +//! `agent.tools` (App Hub's one kernel tool for a contained app), through the +//! script-app path (G3) into the peer's `generic_tools`; its person-lane +//! question is shown and answered in the shell's "Ask News" panel. //! //! **The lanes.** The system agent's lane is a real `peer/input`: a turn on //! the system session makes the model call `peer_send_input`, the kernel @@ -172,7 +172,8 @@ fn register_app(dir: &Path) { let manifest = json!({ "schema": 1, "id": APP, "version": "1", "name": LABEL, "integrity": {"bundle_blake3": digest}, - "capabilities": ["storage", "glance", "news"] + "capabilities": ["storage", "glance", "news"], + "agent": {"profile": "read-only", "tools": ["ask_user_question"], "model": {"needs": ["tool_calling"]}} }); std::fs::write(bundle.join("manifest.json"), serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); let pack = serde_json::to_string(&octosense_app_hub::pack::pack_dir(&bundle).unwrap()).unwrap(); @@ -297,13 +298,11 @@ impl Scenario { let system = octosense_appstore::system::system_app(APP).unwrap(); let bundle = octosense_appstore::system::prepare(&root, &system).expect("the fixture unpacks").0; let loaded = super::script_apps::from_bundle(&bundle).unwrap(); - // App Hub offers a contained app's agent no octos kernel tool - // (`HostLimits::offered_tools`: ledger, net, storage, card), so an - // admitted manifest cannot ask for `ask_user_question`. The fixture's - // agent keeps it through the relay's seam native apps use - // (`agent.generic_tools`). - assert!(loaded.generic.is_empty()); - super::set_generic(APP, vec!["ask_user_question".into()]); + // Its agent keeps `ask_user_question` from its manifest: App Hub + // admits that one kernel tool for a contained app, and the + // script-app path hands it to the peer's `generic_tools`. + assert_eq!(loaded.generic, ["ask_user_question"]); + assert_eq!(super::with_relay(|r| r.catalog.generic(APP, false)), ["ask_user_question"]); let executed: Arc>> = Arc::default(); let inner = super::script_apps::HostServiceExecutor { app: APP.into(), tools: loaded.host_service_tools, families: loaded.families, host_dir: root.join(".host") }; super::set_executor(APP, Some(Arc::new(Counting { inner: Arc::new(inner), ran: executed.clone() }))); @@ -561,9 +560,23 @@ fn the_system_agent_and_the_person_work_with_one_app_agent_at_once() { let asked = requests.iter().find(|r| r["own"].as_str().is_some_and(|u| u.contains("SCN_ASK"))).expect("the person's model request"); eprintln!("[main] the person's lane was shown, while the system agent's turn ran: {}", asked["shared"]); - // 4. The person answers on the shell's question card: their turn ends - // while the system agent's is still parked on its approval. - press(Hit::QuestionOption { id: question.id, label: Some("Team".into()) }); + // 4. The person answers in the shell's "Ask News" panel (G11: the + // question of the app's conversation is shown there, not in the system + // chat): their turn ends while the system agent's is still parked on + // its approval. + crate::app_chat::show_for_tests(crate::apps::AgentApp { id: APP.into(), name: LABEL.into(), octos: Vec::new(), manifest: json!({}), native: false }); + let panel = crate::app_chat::snapshot(); + let routed = panel + .items + .iter() + .find_map(|i| match i { + crate::system_chat::model::Item::Question { id, body, options, answered: None, .. } if body == "Who should see the summary?" => Some((id.clone(), options.clone())), + _ => None, + }) + .unwrap_or_else(|| panic!("the panel shows the question: {:?}", panel.items)); + assert_eq!(routed, (format!("{}{}", crate::app_chat::ROUTED_PREFIX, question.id), vec!["Team".to_string(), "Everyone".to_string()])); + crate::app_chat::answer_option(&routed.0, 1, "Team"); + crate::app_chat::close(); let said = wait_complete(&s, &person, "the person's turn").expect("the person's turn completed"); assert!(said["text"].as_str().unwrap_or("").starts_with("SCN AUDIENCE") && said["text"].to_string().contains("Team"), "{said}"); assert_eq!(said["lane"], "person"); diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index 544433d0..214422b1 100644 --- a/crates/shell/src/host_tools/script_apps.rs +++ b/crates/shell/src/host_tools/script_apps.rs @@ -8,10 +8,11 @@ //! match its manifest, and every rule the store's gate applies holds), from //! the unpacked system app (`octosense_appstore::system::prepare`) or the //! installed bundle (`//bundle`). The manifest's -//! `agent.tools` names both kinds of grant: a plain name (`web_search`) is an -//! octos kernel tool its agent keeps (`generic_tools`; never octos's -//! shell), a dotted one (`mail.send`) another app's shareable tool, granted -//! at install and marked with its owner. +//! `agent.tools` names both kinds of grant: a plain name is an octos kernel +//! tool its agent keeps (`generic_tools`: only App Hub's `KERNEL_TOOLS`, +//! `ask_user_question`, which App Hub alone admits), a dotted one +//! (`mail.send`) another app's shareable tool, granted at install and +//! marked with its owner. //! //! **Where the calls go** ([`HostServiceExecutor`]). A tool the bundle says //! is `implemented_by: "host-service"` runs on the host service of its @@ -83,13 +84,11 @@ pub fn from_bundle(bundle: &Path) -> Result { loaded.host_service_tools.insert(tool.name.clone()); } } - for name in &agent.generic_tools { - if name.contains('.') { - loaded.asks.push(name.clone()); - } else if !super::relay::OCTOS_SHELL.contains(&name.as_str()) { - loaded.generic.push(name.clone()); - } - } + // Dotted names are other apps' tools. Of the kernel's own, App Hub + // lets a contained agent keep only `KERNEL_TOOLS` (`ask_user_question`) + // and refuses the rest at admission; `kernel_tools` is exactly those. + loaded.asks = agent.generic_tools.iter().filter(|name| name.contains('.')).cloned().collect(); + loaded.generic = agent.kernel_tools().into_iter().filter(|name| !super::relay::OCTOS_SHELL.contains(&name.as_str())).collect(); Ok(loaded) } @@ -245,27 +244,35 @@ pub(crate) mod tests { assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["shareable"] == true && t.get("implemented_by").is_none())); assert_eq!(loaded.host_service_tools.len(), 2); assert!(loaded.families.contains("news"), "News is granted its service"); - assert!(loaded.generic.is_empty(), "no agent block: no kernel tools"); + assert_eq!(loaded.generic, ["ask_user_question"], "News's agent may ask the person"); // A tampered bundle is refused (App Hub's digest check). std::fs::write(dir.join("tools.json"), "{}").unwrap(); assert!(from_bundle(&dir).is_err()); let _ = std::fs::remove_dir_all(dir); } - /// The manifest's `agent.tools`: plain names are kernel tools (never - /// octos's shell), dotted ones other apps' tools, granted with their - /// owner. + /// The manifest's `agent.tools`: a plain name is a kernel tool, and App + /// Hub admits only `ask_user_question` (any other, `web_search` or + /// octos's shell, refuses the bundle); dotted ones are other apps' + /// tools, granted with their owner. #[test] fn a_script_apps_agent_block_splits_kernel_tools_from_other_apps_tools() { let dir = stamped_bundle("news", "agent", |_, m| { - m["agent"] = json!({"profile": "read-only", "tools": ["web_search", "ask_user_question", "shell", "mail.send"], - "model": {"needs": ["tool_calling"]}}); + m["agent"] = json!({"profile": "read-only", "tools": ["ask_user_question", "mail.send"], "model": {"needs": ["tool_calling"]}}); }); let loaded = from_bundle(&dir).unwrap(); - assert_eq!(loaded.generic, ["web_search", "ask_user_question"]); + assert_eq!(loaded.generic, ["ask_user_question"]); assert_eq!(loaded.asks, ["mail.send"]); assert_eq!(owner_for("mail.send"), "os.mail", "the system app of its namespace until someone declares it"); let _ = std::fs::remove_dir_all(dir); + for tool in ["web_search", "shell", "read_file"] { + let dir = stamped_bundle("news", tool, |_, m| { + m["agent"] = json!({"profile": "read-only", "tools": ["ask_user_question", tool], "model": {"needs": ["tool_calling"]}}); + }); + let refused = from_bundle(&dir).unwrap_err(); + assert!(refused.contains("may keep only ask_user_question"), "{tool}: {refused}"); + let _ = std::fs::remove_dir_all(dir); + } } struct Probe; diff --git a/docs/adr/0004-native-apps-hosting-and-peers.md b/docs/adr/0004-native-apps-hosting-and-peers.md index 1c1bad5d..890b024b 100644 --- a/docs/adr/0004-native-apps-hosting-and-peers.md +++ b/docs/adr/0004-native-apps-hosting-and-peers.md @@ -352,6 +352,7 @@ How it is turned on and kept from leaking into normal use: ### Follow-ups - [x] **An agent for every app that declares one** (section 4, 2026-09-29): a live run on `625d6cc` found News (tools.json, no `octos.*`) without a peer, an empty `peer_list` for the system agent, Settings listing only Rinx, and no place for the person to talk to an app's agent. Now the shell lists every app with an agent, prepares a consented app's peer, tells the system agent about the ones it cannot list, and draws "Ask ". Still open: a native app's peer exists only while the app is open; the speaker label of a script app's person turns is its app id (the peer's name, kept for resume), not its display name; prepared peers hold a kernel connection, so the kernel's idle stop waits for them. +- [x] **The "Ask " panel's live run, follow-ups** (2026-09-29, [#189](https://github.com/OctoSense-org/OctoSense/pull/189) and the App Hub pin after it): the panel orders both lanes by one clock (each lane's `cursor.seq` is its own; a turn's request line stands where the turn started, handed over by the broker with `turn/started`, since octos writes a turn's user message only when it ends and never for a stopped turn); Stop says "interrupted" once; the system agent is given each peer's slug for `peer_send_input`; and a script app's agent may keep octos's `ask_user_question` (App Hub's one kernel tool for contained apps, `KERNEL_TOOLS`, "Ask you questions" in the store), handed to its peer's `generic_tools` by G3. News's agent keeps it, and its questions of the person's lane are asked in "Ask News". *Still open after the implementation (2026-09-29):* the Windows AppContainer sandbox ([#137](https://github.com/OctoSense-org/OctoSense/issues/137)); the Linux sandbox has run only in CI; process apps see the system agent's lane only through history until Makepad's peer client reads `conversation` frames; a second message on one conversation handle is refused rather than queued; Rinx still has to send turn triggers and hand over its send sheet; the OpenHarmony build with octos#2619's BoringSSL (`btls-sys`) is untested. diff --git a/native-apps.json b/native-apps.json index 481c2bee..c0385fa7 100644 --- a/native-apps.json +++ b/native-apps.json @@ -98,7 +98,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "e8601b80ce104db2e48208094714bdcffdce6b5a", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "8512be70b89b366e6d6ebf93e807188c4313c3c0", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, diff --git a/tools/setup.py b/tools/setup.py index fbaa8844..d1da298d 100644 --- a/tools/setup.py +++ b/tools/setup.py @@ -57,7 +57,8 @@ # Crates whose sources must be unique in every graph, and where they come from. MAKEPAD_CRITICAL = {"makepad-script", "makepad-platform", "makepad-draw", "makepad-widgets", "makepad-live-id"} SINGLE_SOURCE = {"octos-core": "octos", "octos-cli": "octos", "octosense-appstore": "App Hub", - "octosense-app-hub-app": "App Hub", "rinx": "Rinx"} + "octosense-app-hub-app": "App Hub", "octosense-app-policy": "App Hub", + "octosense-app-hub": "App Hub", "rinx": "Rinx"} def git(path, *args, check=True):