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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Related, not build inputs: [OctoScript-App-Design-Flow](https://github.com/OctoS

Each shell runs one [octos](https://github.com/octos-org/octos) agent kernel, started on first use: the APK's `liboctos.so` on Android, in process on OpenHarmony, the binary `OCTOS_APP_CORE_BIN` names on a desktop, none on iOS. The person chooses its models and types keys in the **AI providers** system app, on host sheets; keys stay in the platform's secret store and never reach an app. [`crates/ai-host`](crates/ai-host/README.md) is the shells' one entry point, and [`crates/app-peers`](crates/app-peers/README.md) gives each granted native app its own octos peer (private contexts, workspace and memory `app/<app>/acct-<hash>`), owned by the shell's system agent. A peer's tool approvals are answered only by the person, in that app; the system agent cannot answer them.

What works today: native modules (Rinx) use their peer; AppCard (opt-in) uses the kernel directly. Contained script apps, system or store, **cannot reach the assistant yet**: no shell serves `octos.*` to the Card runner, and the `llm` service manages providers for `os.*` apps only. An app's own agent (`tools.json`, `AGENT.md`, skills, triggers, glance cards) is [ADR 0002](docs/adr/0002-event-driven-app-agents.md), Proposed, with its first pieces merged or in review.
What works today: native modules (Rinx) use their peer; AppCard (opt-in) uses the kernel directly. Contained script apps, system or store, reach it through the `octos` host service in a shell that hosts a kernel: each app gets its own host-owned peer (`card.<app id>`), and tool approvals are declined until the Card runner has an approval sheet. The `llm` service manages providers for `os.*` apps only. An app's own agent (`tools.json`, `AGENT.md`, skills, triggers, glance cards) is [ADR 0002](docs/adr/0002-event-driven-app-agents.md), Proposed, with its first pieces merged or in review.

The architecture, the trust model, what each kind of app can use, the plan with its status, and how to run and test it locally: [docs/ai-services.md](docs/ai-services.md). For app developers: OctoScript-App-Design-Flow's [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.md).

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/000

每个 Shell 运行一个 [octos](https://github.com/octos-org/octos) Agent 内核,首次使用时启动:Android 上是 APK 中的 `liboctos.so`,OpenHarmony 上在进程内运行,桌面端运行 `OCTOS_APP_CORE_BIN` 指定的二进制,iOS 上没有。用户在系统应用 **AI providers** 中、在宿主面板上选择模型并输入密钥;密钥保存在平台的密钥存储中,永远不会到达应用。[`crates/ai-host`](crates/ai-host/README.md) 是两个 Shell 的统一入口,[`crates/app-peers`](crates/app-peers/README.md) 为每个获授权的原生应用分配自己的 octos peer(私有的上下文、工作区和记忆 `app/<app>/acct-<hash>`),归 Shell 的系统 Agent 所有。peer 的工具审批只能由用户在该应用中回答,系统 Agent 无法代答。

目前可用的:原生模块(Rinx)使用自己的 peer;AppCard(需主动开启)直接使用内核。隔离运行的脚本应用,无论系统应用还是商店应用,**目前都还无法使用助手**:没有任何 Shell 向 Card runner 提供 `octos.*`,而 `llm` 服务只为 `os.*` 应用管理提供方。应用自己的 Agent(`tools.json`、`AGENT.md`、skills、触发器、glance 卡片)见 [ADR 0002](docs/adr/0002-event-driven-app-agents.md)(Proposed),其中最早的几部分已合入或正在评审。
目前可用的:原生模块(Rinx)使用自己的 peer;AppCard(需主动开启)直接使用内核。隔离运行的脚本应用,无论系统应用还是商店应用,在托管了内核的 Shell 中通过 `octos` 宿主服务使用助手:每个应用有自己的、由宿主拥有的 peer(`card.<应用 id>`),在 Card runner 有审批面板之前工具审批一律被拒绝。`llm` 服务只为 `os.*` 应用管理提供方。应用自己的 Agent(`tools.json`、`AGENT.md`、skills、触发器、glance 卡片)见 [ADR 0002](docs/adr/0002-event-driven-app-agents.md)(Proposed),其中最早的几部分已合入或正在评审。

架构、信任模型、各类应用能用什么、规划及其状态,以及如何在本地运行和测试:[docs/ai-services.zh-CN.md](docs/ai-services.zh-CN.md)。面向应用开发者:OctoScript-App-Design-Flow 的 [AI-SERVICES](https://github.com/OctoSense-org/OctoScript-App-Design-Flow/blob/main/docs/AI-SERVICES.zh-CN.md)。

Expand Down
3 changes: 3 additions & 0 deletions crates/ai-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ makepad-app-module = { workspace = true }
octosense-llm-config = { workspace = true }
# The contract (OCTOS_SERVICES, injection) always; the broker with `octos-core`.
octosense-app-peers = { workspace = true }
# The Card runner's host-service API: the `octos` service for contained apps.
octosense-appstore = { workspace = true }
serde_json = { workspace = true }
octosense-kernel = { workspace = true, optional = true }
octosense-llm-service = { workspace = true, optional = true }

Expand Down
235 changes: 235 additions & 0 deletions crates/ai-host/src/contained.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//! `octos` for contained apps: the Card runner's assistant service.
//!
//! A store app (an App Hub bundle the Card runner hosts) that declares
//! `octos.*` services calls `host.request("octos.turn.start", {text}, …)`.
//! The isolate's gate already refused every service the manifest did not
//! declare; what reaches this service is handed to ONE octos peer per app,
//! owned by the shell's system agent on the shell's kernel — the same
//! host-owned peer contract Rinx uses (ADR 0007), named `card.<app id>` so a
//! store app can never share a native module's peer or memory.
//!
//! What an app sends is input text only. It never names a session, profile,
//! workspace or provider, and it cannot decide a tool approval: the Card
//! runner has no approval sheet yet, so every approval the peer raises is
//! declined here and listed in the reply's `denied_approvals`.
//!
//! 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.

use octosense_app_peers::{ContextEvent, ContextOp, ContextSpec, EventSink, OctosAppService, OctosContext};
use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, Weak};

/// A contained app's peer is `card.<app id>`; no native module id starts so.
pub const PEER_PREFIX: &str = "card.";
/// The Card runner has no accounts: every app's peer acts for the device.
pub const ACCOUNT: &str = "device";
/// The longest turn text an app may send.
pub const MAX_TEXT_BYTES: usize = 32 * 1024;
/// The largest reply delivered to an app (serialized JSON).
pub const MAX_REPLY_BYTES: usize = 2 * 1024 * 1024;
/// The longest peer id the kernel's memory namespace takes.
const MAX_PEER_ID: usize = 64;

pub const TURNED_OFF: &str = "The assistant is turned off for apps on this device";
pub const UNAVAILABLE: &str = "The assistant is not available on this device";
pub const UNSUPPORTED_ARGS: &str = "Unsupported Octos arguments";
pub const BAD_TEXT: &str = "Provide text (at most 32 KiB)";
pub const NO_SHEET: &str = "The assistant has no sheet; octos calls come from the app";

/// Where contained apps' peers come from.
pub trait PeerFactory: Send + Sync {
/// The scoped assistant service for `app_id`'s peer `peer_id`, or `None`
/// when this device cannot give one.
fn launch(&self, peer_id: &str, app_id: &str) -> Option<Arc<dyn OctosAppService>>;
}

/// The peer id of `app_id`, or why it cannot name one.
pub fn peer_id(app_id: &str) -> Result<String, String> {
let id = format!("{PEER_PREFIX}{app_id}");
if namespace_segment(&id) {
Ok(id)
} else {
Err(format!(
"The app id {app_id:?} cannot name an assistant peer (lowercase letters, digits, '.', '_' or '-', at most {} characters)",
MAX_PEER_ID - PEER_PREFIX.len()
))
}
}

/// The kernel's memory-namespace segment rule (`octosense_app_peers::hosted`).
fn namespace_segment(id: &str) -> bool {
let bytes = id.as_bytes();
!bytes.is_empty()
&& bytes.len() <= MAX_PEER_ID
&& (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
&& bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
}

/// The operation an app's call asks for. Only `text` for a turn; nothing for
/// the rest.
pub fn parse(service: &str, args: &Value) -> Result<ContextOp, String> {
let allowed: &[&str] = match service {
"octos.turn.start" => &["text"],
"octos.session.open" | "octos.session.history" | "octos.turn.interrupt" => &[],
other => return Err(format!("Unknown Octos service {other}")),
};
if args.as_object().is_none_or(|o| o.keys().any(|k| !allowed.contains(&k.as_str()))) {
return Err(UNSUPPORTED_ARGS.into());
}
Ok(match service {
"octos.turn.start" => ContextOp::Turn {
text: args["text"]
.as_str()
.filter(|s| !s.trim().is_empty() && s.len() <= MAX_TEXT_BYTES)
.ok_or(BAD_TEXT)?
.to_owned(),
},
"octos.session.open" => ContextOp::Open,
"octos.session.history" => ContextOp::History,
_ => ContextOp::Interrupt,
})
}

struct AppPeer {
peer: String,
service: Arc<dyn OctosAppService>,
context: Option<Arc<dyn OctosContext>>,
generation: u64,
}

/// The `octos` host service for the Card runner's apps.
pub struct ContainedOctos {
enabled: bool,
factory: Arc<dyn PeerFactory>,
apps: HashMap<String, AppPeer>,
}

impl ContainedOctos {
/// `enabled` is the shell's switch ([`crate::Policy::contained_apps`]).
pub fn new(enabled: bool, factory: Arc<dyn PeerFactory>) -> Self {
ContainedOctos { enabled, factory, apps: HashMap::new() }
}

/// The app's open request context, creating its peer and (re)opening
/// the context as needed.
fn context_for(&mut self, app_id: &str) -> Result<Arc<dyn OctosContext>, String> {
if !self.apps.contains_key(app_id) {
let peer = peer_id(app_id)?;
let service = self.factory.launch(&peer, app_id).ok_or(UNAVAILABLE)?;
service.set_account(Some(ACCOUNT));
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");
if let Some(context) = app.context.as_ref().filter(|c| c.is_open()) {
return Ok(context.clone());
}
app.generation += 1;
let context = app.service.open_context(ContextSpec {
account: ACCOUNT.to_owned(),
instance: format!("{}-g{}", app.peer, app.generation),
services: app.service.services(),
})?;
app.context = Some(context.clone());
Ok(context)
}
}

/// One call's answer: sent exactly once.
type Once = Arc<Mutex<Option<Replier>>>;

fn answer(once: &Once, result: Result<Value, String>) {
if let Some(reply) = once.lock().unwrap_or_else(|e| e.into_inner()).take() {
reply.send(result);
}
}

/// The reply an app receives: the kernel's result, with the tools declined
/// during the call, bounded.
fn finish(result: Result<Value, String>, denied: &[String]) -> Result<Value, String> {
let mut value = result?;
if !denied.is_empty() {
if let Some(object) = value.as_object_mut() {
object.insert("denied_approvals".into(), denied.iter().cloned().map(Value::String).collect());
}
}
if value.to_string().len() > MAX_REPLY_BYTES {
return Err(format!("The assistant's reply is over MAX_REPLY_BYTES ({MAX_REPLY_BYTES} bytes)"));
}
Ok(value)
}

/// Where one call's events go: approvals are declined, the completion is
/// the app's answer, streamed text is dropped (the answer carries it).
fn sink(context: Weak<dyn OctosContext>, once: Once) -> EventSink {
let denied: Arc<Mutex<Vec<String>>> = Arc::default();
Arc::new(move |event| match event {
ContextEvent::Data(data) => {
if data["method"] != "approval/requested" {
return;
}
let params = &data["params"];
let Some(id) = params["approval_id"].as_str().map(str::to_owned) else { return };
let title = params["title"].as_str().unwrap_or("a tool").to_owned();
denied.lock().unwrap_or_else(|e| e.into_inner()).push(title);
// Declined off this thread: the context may be delivering this
// event under its own lock.
if let Some(context) = context.upgrade() {
std::thread::spawn(move || {
let _ = context.call(ContextOp::Approval { id, approve: false }, Arc::new(|_| {}));
});
}
}
ContextEvent::Complete(result) => {
let denied = denied.lock().unwrap_or_else(|e| e.into_inner()).clone();
answer(&once, finish(result, &denied));
}
})
}

impl HostService for ContainedOctos {
fn family(&self) -> &'static str {
"octos"
}

fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) {
if call.from_sheet {
return reply.send(Err(NO_SHEET.into()));
}
if !self.enabled {
return reply.send(Err(TURNED_OFF.into()));
}
let op = match parse(&call.service, &call.args) {
Ok(op) => op,
Err(e) => return reply.send(Err(e)),
};
let context = match self.context_for(&call.app_id) {
Ok(context) => context,
Err(e) => return reply.send(Err(e)),
};
let once: Once = Arc::new(Mutex::new(Some(reply)));
if let Err(e) = context.call(op, sink(Arc::downgrade(&context), once.clone())) {
answer(&once, Err(e));
}
}
}

/// The shell's peers: host-owned app peers on the shell's kernel.
#[cfg(kernel)]
pub(crate) struct KernelPeers;

#[cfg(kernel)]
impl PeerFactory for KernelPeers {
fn launch(&self, peer_id: &str, app_id: &str) -> Option<Arc<dyn OctosAppService>> {
let services = octosense_app_peers::OCTOS_SERVICES;
crate::host_policy().allow(peer_id, services);
let broker = octosense_app_peers::hosted::launch(peer_id, app_id, services, crate::host_policy())?;
Some(Arc::new(broker))
}
}

#[cfg(test)]
mod tests;
Loading
Loading