You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/25 04:08:49 UTC
(maka) branch feat/runtime-host-rust updated: feat(plan): complete the native planning backend
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
new af33d4d039 feat(plan): complete the native planning backend
af33d4d039 is described below
commit af33d4d0395372af6b03fe1149ccc5618fcf0f7a
Author: Wang <m4...@gmail.com>
AuthorDate: Fri Sep 25 12:08:05 2026 +0800
feat(plan): complete the native planning backend
Connect planning and approved execution through public plugin behaviors,
tools and Remote controls. Persist dispatch and cancellation intent, recover
original receipts after restart or authorization renewal, and finalize
reported progress only after successful Host settlement.
Expose frozen behavior identity to Rust and JavaScript tool bindings, enforce
planning tool ceilings, and document the backend API and remaining parity
gaps. Plan UI remains deferred.
Validation: 21 assistant/tool tests and 12 focused Host integration tests
passed; the Host suite, CLI check and SDK type checks also passed after
syncing the concurrent branch changes. Strict Clippy and staged source
checks passed. Full-workspace formatting, TS build/type checks and Desktop
knip still report issues outside this change.
Generated-by: Codex
---
Cargo.lock | 1 +
crates/agent/src/steps.rs | 3 +-
crates/assistant/Cargo.toml | 1 +
crates/assistant/README.md | 100 ++++
crates/assistant/README.zh-CN.md | 86 ++++
crates/assistant/src/plan.rs | 36 ++
crates/assistant/src/plan/artifact.rs | 1 +
crates/assistant/src/plan/owner.rs | 400 +++++++++++++++
crates/assistant/src/plan/plugin.rs | 96 ++++
crates/assistant/src/plan/plugin/behavior.rs | 97 ++++
crates/assistant/src/plan/plugin/remote.rs | 312 ++++++++++++
crates/assistant/src/plan/plugin/tools.rs | 313 ++++++++++++
crates/assistant/src/plan/repository.rs | 36 +-
crates/assistant/src/plan/transition.rs | 140 ++++-
crates/assistant/tests/plan.rs | 26 +-
crates/assistant/tests/plan_history.rs | 12 +-
.../src/execution/prepare/environment.rs | 17 +-
crates/runtime-host/src/execution/resume.rs | 7 -
crates/runtime-host/src/plugins.rs | 1 +
.../runtime-host/src/plugins/javascript/binding.rs | 2 +-
crates/runtime-host/src/plugins/plan.rs | 56 ++
crates/runtime-host/src/server.rs | 1 +
crates/runtime-host/tests/integration/main.rs | 1 +
.../runtime-host/tests/integration/plan_plugin.rs | 566 +++++++++++++++++++++
crates/runtime/src/execution/behavior.rs | 9 +
crates/tool-catalog/src/availability.rs | 8 +
crates/tool-catalog/src/plugins/binding.rs | 2 +
crates/tools/src/dispatch.rs | 5 +
docs/rust-parity.md | 82 +--
docs/rust-parity.zh-CN.md | 69 ++-
packages/plugin-sdk/README.md | 10 +
packages/plugin-sdk/README.zh-CN.md | 9 +
packages/plugin-sdk/src/host.ts | 2 +
33 files changed, 2408 insertions(+), 99 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 9d89253e9f..dd4fe4363d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4893,6 +4893,7 @@ dependencies = [
"tokio",
"tokio-util",
"unicode-normalization",
+ "uuid",
]
[[package]]
diff --git a/crates/agent/src/steps.rs b/crates/agent/src/steps.rs
index 049b62b036..419ea0015e 100644
--- a/crates/agent/src/steps.rs
+++ b/crates/agent/src/steps.rs
@@ -43,7 +43,8 @@ pub(super) async fn run(
input.configuration.tool_mode,
inner.cells.clone(),
)
- .with_model(input.provider.tool_context());
+ .with_model(input.provider.tool_context())
+ .with_behavior(input.configuration.orchestration_mode.clone());
let result = std::panic::AssertUnwindSafe(async {
use maka_runtime::handoff::CompactionBudget;
let mut compaction = CompactionBudget::Available;
diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml
index de0427cb4b..99749ff887 100644
--- a/crates/assistant/Cargo.toml
+++ b/crates/assistant/Cargo.toml
@@ -36,6 +36,7 @@ sha2.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
+uuid.workspace = true
unicode-normalization = "0.1.25"
[dev-dependencies]
diff --git a/crates/assistant/README.md b/crates/assistant/README.md
new file mode 100644
index 0000000000..10babef621
--- /dev/null
+++ b/crates/assistant/README.md
@@ -0,0 +1,100 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Assistant plugins
+
+[简体中文](README.zh-CN.md)
+
+Native assistant, Todo, Recall and Plan plugins using public Host capabilities.
+
+## Plan
+
+`maka.plan` owns proposals, approvals, progress and recovery in namespaced plugin
+storage. Host owns permissions, admission, canonical execution and settlement.
+The backend operates without a frontend bundle; Plan UI is deferred.
+
+A model Session with collaboration mode `plan` selects `default:plan`. Its tool
+ceiling permits inspection, questions and proposal submission, excluding Shell,
+workspace edits and autonomous workflows. Full bypass also permits explicit
+user-requested file/Shell effects; it does not approve implementation by itself.
+
+- `SubmitPlan` saves a versioned proposal and ends the planning Turn after durable
+ tool settlement.
+- Approving the exact proposal version saves a frozen submission and an explicit
+ Session execution grant. It does not grant sandbox permissions. Execution uses
+ the registered `maka.plan.execute` behavior without changing the Session default.
+- `update_plan` reports all step IDs, with at most one in progress. The Plan is
+ completed only when every step is completed/skipped and Host reports successful
+ execution. Step completion remains a model report, not independent verification.
+- `cancel_plan` records cancellation and ends the current Turn. Cancellation
+ always targets the original operation, never the Session's latest unrelated Turn.
+
+Tools are visible only in their corresponding frozen behavior and are direct-only
+in Code Mode. Artifact/progress context is captured afresh per logical model step;
+physical retries retain that captured context.
+
+## Backend controls
+
+Bind the standalone Remote with
+`{ packageId: 'maka.plan', method: 'manage', sessionId }`.
+The Session comes from the Host binding, not request data.
+
+| Request | Behavior |
+| --- | --- |
+| `read` | Current revision, proposal metadata, execution progress and phase. |
+| `artifact { source, revision? }` | Full proposal or execution artifact; source is `proposal` or `execution`. |
+| `history { throughRevision?, after? }` | One summary per page under a fixed revision watermark. |
+| `control { operationId, expectedRevision, action }` | An immutable, revision-checked decision; equal retries return the original receipt. |
+
+Control actions are tagged by `kind`:
+
+- `approve { proposalId, proposalRevision, grant }`.
+- `revise { proposalId }` or `abandon { proposalId }`.
+- `resume { executionId, grant }` after confirmed interruption.
+- `cancel { executionId, reason, grant? }`.
+- `reconcile { executionId, grant }` to renew observation/admission authority for
+ unsettled original work, including after revocation.
+
+Obtain grants through the existing application `plugin.authorization` operation
+for this Remote binding, with the exact Session target and `executions` capability.
+Reads and old receipts still require current Session access. A new approval or
+renewal validates current consent; an old accepted decision does not require its
+former background grant to remain live.
+
+## Recovery and bounds
+
+Dispatch intent precedes Host submission. Restart queries the original operation;
+any retry preserves its identity and frozen content. Pending cancellation never
+resubmits uncertain work. If its receipt is still unknown, the Plan remains
+unsettled, cannot be replaced, and does not keep an idle Host awake.
+
+Failed/cancelled Host execution or successful execution with unfinished steps
+interrupts the Plan. Explicit resume freezes the recorded progress into a new
+submission. It never automatically replays unknown effects. A sealed Host handoff
+remains owned by the same operation; resume that Run through public Session
+controls. Plugin retirement stops new plugin work while Host settles accepted work.
+Reactivation reconciles receipts under current consent.
+
+Artifacts allow 1–50 steps and at most 40 KiB of encoded JSON; progress is limited
+to 12 KiB. Remote summaries omit grants and duplicated frozen submission text;
+artifacts are fetched separately within the 64 KiB Remote limit.
+
+Domain tests cover actual SQLite persistence, exact retries and stale identities.
+Host integration covers planning restrictions, approval, settlement, retirement,
+restart, revocation/renewal, explicit resume and cancellation without replay.
diff --git a/crates/assistant/README.zh-CN.md b/crates/assistant/README.zh-CN.md
new file mode 100644
index 0000000000..8d6445cce6
--- /dev/null
+++ b/crates/assistant/README.zh-CN.md
@@ -0,0 +1,86 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Assistant 插件
+
+[English](README.md)
+
+通过公共 Host 能力实现的原生 Assistant、Todo、Recall 和 Plan 插件。
+
+## Plan
+
+`maka.plan` 在插件命名空间存储中拥有提案、审批、进度和恢复策略;Host 拥有权限、
+准入、规范执行及结算。后端不依赖前端 bundle,Plan UI 后置。
+
+模型 Session 的 collaboration mode 为 `plan` 时选择 `default:plan`。工具上限只允许
+检查、提问和提交提案,排除 Shell、工作区编辑及自主工作流。完全绕过时额外允许用户
+明确要求的文件/Shell 副作用,但不因此自动批准实施。
+
+- `SubmitPlan` 保存带版本的提案,在工具结果持久化后结束规划 Turn。
+- 审批精确提案版本后,持久化冻结的提交和显式 Session 执行授权,不授予沙箱权限。
+ 执行使用已注册的 `maka.plan.execute` behavior,不修改 Session 默认模式。
+- `update_plan` 报告全部步骤 ID,最多一个步骤进行中。只有全部步骤完成/跳过,
+ 且 Host 执行成功后,Plan 才完成;步骤完成仍是模型报告,不是独立验收。
+- `cancel_plan` 持久化取消意图并结束当前 Turn;取消始终针对原操作,不停止该
+ Session 后来无关的 Turn。
+
+工具只在对应的冻结 behavior 中可见,在 Code Mode 中也必须直接调用。计划正文和进度
+每逻辑模型步骤重新捕获,物理重试保持原快照。
+
+## 后端控制
+
+通过 `{ packageId: 'maka.plan', method: 'manage', sessionId }` 绑定独立 Remote。
+Session 来自 Host 绑定,不从请求正文获取。
+
+| 请求 | 行为 |
+| --- | --- |
+| `read` | 当前 revision、提案元数据、执行进度及阶段。 |
+| `artifact { source, revision? }` | 完整提案或执行计划,source 为 `proposal` 或 `execution`。 |
+| `history { throughRevision?, after? }` | 固定 revision 水位下的历史,每页一份摘要。 |
+| `control { operationId, expectedRevision, action }` | 带修订检查的不可变决策;相同重试返回原回执。 |
+
+action 使用 `kind` 区分:
+
+- `approve { proposalId, proposalRevision, grant }`。
+- `revise { proposalId }` 或 `abandon { proposalId }`。
+- `resume { executionId, grant }`:确认中断后显式续跑。
+- `cancel { executionId, reason, grant? }`。
+- `reconcile { executionId, grant }`:为尚未结算的原工作更新观察/准入授权,
+ 包括授权撤销后的恢复。
+
+应用通过现有 `plugin.authorization` 为此 Remote 绑定获得授权,目标必须是精确的
+Session,能力包含 `executions`。读取及原回执查询仍检查当前 Session 访问权;新的
+审批或授权更新检查当前 consent,已接受的旧决策不要求原后台授权继续存活。
+
+## 恢复与边界
+
+先持久化派发意图,再提交 Host。重启查询原操作,重试保持原身份和冻结内容。
+待取消的未知工作不会重投;回执仍未知时,Plan 保持未结算且不能被替换,但不阻止
+空闲 Host 退出。
+
+Host 执行失败/取消,或成功结束但步骤未完成,都会中断 Plan。显式恢复把已有进度
+冻结进新提交,不自动重放未知副作用。封口的 Host 交接仍属于原操作,应通过公共
+Session 控制恢复该 Run。插件停用停止新插件工作,Host 继续结算已接受执行;
+重新启用后在当前授权下协调原回执。
+
+计划允许 1–50 个步骤,编码后的 JSON 最多 40 KiB;进度最多 12 KiB。Remote 摘要
+省略授权引用和重复的冻结提交正文,计划正文另行获取,保持在 64 KiB Remote 上限内。
+
+领域测试覆盖真实 SQLite 持久化、精确重试和过期身份;Host 集成测试覆盖规划限制、
+审批、结算、停用、重启、授权撤销/更新、显式恢复和不重放的取消。
diff --git a/crates/assistant/src/plan.rs b/crates/assistant/src/plan.rs
index f2ad4717a9..6ce13614d1 100644
--- a/crates/assistant/src/plan.rs
+++ b/crates/assistant/src/plan.rs
@@ -19,6 +19,8 @@
//! Plan intent and model-reported progress. Neither is Host execution authority.
mod artifact;
+mod owner;
+pub mod plugin;
pub mod repository;
mod transition;
pub use artifact::{Artifact, Complexity, Step};
@@ -37,6 +39,12 @@ pub enum Error {
Corrupt(String),
#[error(transparent)]
Storage(#[from] maka_plugins::storage::StoreError),
+ #[error(transparent)]
+ Execution(#[from] maka_plugins::execution::CommandError),
+ #[error(transparent)]
+ Capability(#[from] maka_plugins::Error),
+ #[error(transparent)]
+ Tool(#[from] maka_runtime::tools::ToolError),
}
/// An exact operation is replayable even after subsequent state changes.
@@ -72,6 +80,17 @@ pub enum Command {
proposal_id: String,
proposal_revision: u64,
behavior: BehaviorId,
+ grant: maka_plugins::authorization::Id,
+ },
+ Dispatch {
+ execution_id: String,
+ },
+ Defer {
+ execution_id: String,
+ },
+ Reconcile {
+ execution_id: String,
+ grant: maka_plugins::authorization::Id,
},
Accept {
execution_id: String,
@@ -95,11 +114,25 @@ pub enum Command {
},
Resume {
execution_id: String,
+ grant: maka_plugins::authorization::Id,
},
Cancel {
execution_id: String,
reason: String,
+ grant: Option<maka_plugins::authorization::Id>,
},
+ Settle {
+ execution_id: String,
+ invocation: Invocation,
+ outcome: Settlement,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Settlement {
+ Completed,
+ Interrupted { reason: String },
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -142,6 +175,9 @@ pub struct Execution {
pub steps: Vec<Progress>,
/// Frozen before calling Host. Recover the exact operation, never a new attempt.
pub request: Submit,
+ pub grant: maka_plugins::authorization::Id,
+ pub dispatched: bool,
+ pub cancellation: Option<String>,
pub phase: Phase,
pub updated_at: u64,
}
diff --git a/crates/assistant/src/plan/artifact.rs b/crates/assistant/src/plan/artifact.rs
index 1f8dce4c34..df63a1876d 100644
--- a/crates/assistant/src/plan/artifact.rs
+++ b/crates/assistant/src/plan/artifact.rs
@@ -152,6 +152,7 @@ mod tests {
snapshot
.apply(
&Command::Approve {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
proposal_id: snapshot.proposal.as_ref().unwrap().id.clone(),
proposal_revision: 1,
behavior: Default::default(),
diff --git a/crates/assistant/src/plan/owner.rs b/crates/assistant/src/plan/owner.rs
new file mode 100644
index 0000000000..18343bcad3
--- /dev/null
+++ b/crates/assistant/src/plan/owner.rs
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{
+ Command, Error, Phase, Request, Settlement, Snapshot, invalid, repository::Repository,
+};
+use maka_plugins::{
+ authorization::{Capability, Id, Target},
+ background::BackgroundWork,
+ execution::{CommandError, Commands, Observation, Progress},
+ host::Services,
+};
+use maka_runtime::event::{Invocation, InvocationOutcome};
+use sha2::{Digest, Sha256};
+use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+};
+use tokio::sync::{Mutex, Notify};
+use tokio_util::sync::CancellationToken;
+
+pub(super) struct Owner {
+ pub storage: Arc<dyn maka_plugins::storage::Store>,
+ pub executions: Arc<dyn maka_plugins::execution::Access>,
+ authorizations: Arc<dyn maka_plugins::authorization::Access>,
+ entry: String,
+ gate: Mutex<()>,
+ pending: AtomicBool,
+ wake: Notify,
+}
+
+impl BackgroundWork for Owner {
+ fn is_pending(&self) -> bool {
+ self.pending.load(Ordering::Acquire)
+ }
+ fn wake(&self) {
+ self.wake.notify_one();
+ }
+}
+
+impl Owner {
+ pub fn new(host: Services, entry: String) -> Arc<Self> {
+ Arc::new(Self {
+ storage: host.storage,
+ executions: host.executions,
+ authorizations: host.authorizations,
+ entry,
+ gate: Mutex::new(()),
+ pending: AtomicBool::new(true),
+ wake: Notify::new(),
+ })
+ }
+
+ pub fn repository(&self, session: &str) -> Result<Repository, Error> {
+ Repository::new(self.storage.clone(), &self.entry, session)
+ }
+
+ pub fn changed(&self) {
+ self.pending.store(true, Ordering::Release);
+ self.wake.notify_one();
+ }
+
+ pub async fn grant(&self, grant: Id, session: &str) -> Result<(), Error> {
+ let owned = self.authorizations.open(grant).await?;
+ let valid = owned.grant.request.target
+ == Target::Session {
+ session_id: session.into(),
+ }
+ && owned
+ .grant
+ .request
+ .capabilities
+ .contains(&Capability::Executions);
+ owned.call.finish().await?;
+ if valid {
+ Ok(())
+ } else {
+ Err(invalid(
+ "Plan requires explicit execution consent for this Session",
+ ))
+ }
+ }
+
+ pub async fn apply(&self, session: &str, request: &Request) -> Result<Snapshot, Error> {
+ let _gate = self.gate.lock().await;
+ let result = self.repository(session)?.apply(request, now()?).await?;
+ self.changed();
+ Ok(result)
+ }
+
+ async fn advance(
+ &self,
+ session: &str,
+ snapshot: &Snapshot,
+ command: Command,
+ ) -> Result<Snapshot, Error> {
+ let operation_id = identity(&("observe", snapshot.revision, &command))?;
+ self.apply(
+ session,
+ &Request {
+ operation_id,
+ expected_revision: snapshot.revision,
+ command,
+ },
+ )
+ .await
+ }
+
+ /// Check the real current invocation and this plugin's original receipt.
+ /// A Session ID or a model-supplied execution ID grants no progress authority.
+ pub async fn active(
+ &self,
+ commands: &dyn Commands,
+ invocation: &Invocation,
+ ) -> Result<(Snapshot, maka_plugins::execution::Receipt), Error> {
+ let session = &invocation.session_id;
+ let snapshot = self.repository(session)?.current().await?;
+ let execution = snapshot
+ .execution
+ .as_ref()
+ .ok_or_else(|| invalid("No Plan execution"))?;
+ if !matches!(
+ execution.phase,
+ Phase::AwaitingAdmission | Phase::Active { .. }
+ ) {
+ return Err(Error::Conflict);
+ }
+ let observation = commands
+ .query(execution.request.operation_id.clone())
+ .await?;
+ let activity = commands.activity(session.clone()).await?;
+ if observation.receipt.invocation.turn_id != invocation.turn_id
+ || observation.receipt.invocation.session_id != *session
+ || !matches!(
+ observation.progress,
+ Progress::Running | Progress::WaitingForUser
+ )
+ || !activity
+ .execution
+ .is_some_and(|current| current.invocation == *invocation)
+ {
+ return Err(Error::Conflict);
+ }
+ let snapshot = self.accept(session, &observation.receipt).await?;
+ Ok((snapshot, observation.receipt))
+ }
+
+ async fn accept(
+ &self,
+ session: &str,
+ receipt: &maka_plugins::execution::Receipt,
+ ) -> Result<Snapshot, Error> {
+ let _gate = self.gate.lock().await;
+ let repo = self.repository(session)?;
+ let snapshot = repo.current().await?;
+ let execution = snapshot.execution.as_ref().ok_or(Error::Conflict)?;
+ match &execution.phase {
+ Phase::AwaitingAdmission => {
+ let command = Command::Accept {
+ execution_id: execution.id.clone(),
+ receipt: receipt.clone(),
+ };
+ repo.apply(
+ &Request {
+ operation_id: identity(&("accept", snapshot.revision, &command))?,
+ expected_revision: snapshot.revision,
+ command,
+ },
+ now()?,
+ )
+ .await
+ }
+ Phase::Active { receipt: current } if current == receipt => Ok(snapshot),
+ _ => Err(Error::Conflict),
+ }
+ }
+
+ pub async fn run(self: Arc<Self>, stop: CancellationToken) -> Result<(), String> {
+ loop {
+ if stop.is_cancelled() {
+ return Ok(());
+ }
+ let sessions = self
+ .repository_sessions()
+ .await
+ .map_err(|error| error.to_string())?;
+ let mut pending = false;
+ for session in sessions {
+ let result = tokio::select! {
+ biased;
+ _ = stop.cancelled() => return Ok(()),
+ result = self.reconcile(&session) => result,
+ };
+ // A revoked grant or an unavailable Host cannot justify keeping it
+ // awake. Durable intent remains for explicit renewal and recovery.
+ pending |= result.unwrap_or(false);
+ }
+ self.pending.store(pending, Ordering::Release);
+ tokio::select! {
+ biased;
+ _ = stop.cancelled() => return Ok(()),
+ _ = self.wake.notified() => {},
+ _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {},
+ }
+ }
+ }
+
+ async fn repository_sessions(&self) -> Result<Vec<String>, Error> {
+ Repository::sessions(self.storage.as_ref(), &self.entry).await
+ }
+
+ async fn reconcile(&self, session: &str) -> Result<bool, Error> {
+ let snapshot = self.repository(session)?.current().await?;
+ let Some(execution) = snapshot.execution.as_ref() else {
+ return Ok(false);
+ };
+ if !matches!(
+ execution.phase,
+ Phase::AwaitingAdmission | Phase::Active { .. }
+ ) {
+ return Ok(false);
+ }
+ let commands = self.executions.restore(execution.grant).await?;
+ match commands.query(execution.request.operation_id.clone()).await {
+ Ok(observation) => self.observe(session, commands.as_ref(), observation).await,
+ Err(CommandError::NotFound) if matches!(execution.phase, Phase::AwaitingAdmission) => {
+ if execution.cancellation.is_some() {
+ // An earlier dispatch may still arrive. Cancellation never
+ // resubmits it, changes its ID, or assumes absence is proof.
+ return Ok(false);
+ }
+ let first_dispatch = !execution.dispatched;
+ if first_dispatch {
+ self.advance(
+ session,
+ &snapshot,
+ Command::Dispatch {
+ execution_id: execution.id.clone(),
+ },
+ )
+ .await?;
+ }
+ match commands.submit(execution.request.clone()).await {
+ Ok(_) => {
+ let observation = commands
+ .query(execution.request.operation_id.clone())
+ .await?;
+ self.observe(session, commands.as_ref(), observation).await
+ }
+ Err(CommandError::Busy | CommandError::Draining) if first_dispatch => {
+ let current = self.repository(session)?.current().await?;
+ self.advance(
+ session,
+ ¤t,
+ Command::Defer {
+ execution_id: execution.id.clone(),
+ },
+ )
+ .await?;
+ Ok(true)
+ }
+ Err(
+ error @ (CommandError::Denied
+ | CommandError::Revoked
+ | CommandError::Invalid(_)
+ | CommandError::Conflict
+ | CommandError::Unavailable(_)),
+ ) if first_dispatch => {
+ let current = self.repository(session)?.current().await?;
+ self.advance(
+ session,
+ ¤t,
+ Command::Reject {
+ execution_id: execution.id.clone(),
+ request_digest: execution.request.digest().map_err(invalid)?,
+ reason: bounded(&error.to_string()),
+ },
+ )
+ .await?;
+ Ok(false)
+ }
+ Err(error) => Err(error.into()),
+ }
+ }
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ async fn observe(
+ &self,
+ session: &str,
+ commands: &dyn Commands,
+ mut observation: Observation,
+ ) -> Result<bool, Error> {
+ let mut snapshot = self.repository(session)?.current().await?;
+ let Some(execution) = snapshot.execution.as_ref() else {
+ return Ok(false);
+ };
+ if !matches!(
+ execution.phase,
+ Phase::AwaitingAdmission | Phase::Active { .. }
+ ) || execution.request.digest().map_err(invalid)? != observation.receipt.content_digest
+ {
+ return Ok(false);
+ }
+ if matches!(execution.phase, Phase::AwaitingAdmission) {
+ snapshot = self
+ .advance(
+ session,
+ &snapshot,
+ Command::Accept {
+ execution_id: execution.id.clone(),
+ receipt: observation.receipt.clone(),
+ },
+ )
+ .await?;
+ }
+ let execution = snapshot.execution.as_ref().ok_or(Error::Conflict)?;
+ if execution.cancellation.is_some()
+ && !matches!(observation.progress, Progress::Ended { .. })
+ {
+ observation = commands
+ .cancel(execution.request.operation_id.clone())
+ .await?;
+ }
+ let Progress::Ended { outcome } = observation.progress else {
+ // Host owns accepted work. A sealed handoff never becomes a fresh
+ // submission here; resume it through the public Session controls.
+ return Ok(false);
+ };
+ let outcome = match outcome {
+ InvocationOutcome::Completed => Settlement::Completed,
+ InvocationOutcome::Failed { class, message } => Settlement::Interrupted {
+ reason: bounded(&message.unwrap_or(class)),
+ },
+ InvocationOutcome::Cancelled { source } => Settlement::Interrupted {
+ reason: bounded(&format!("Host execution cancelled: {source}")),
+ },
+ _ => return Err(invalid("Unexpected Plan execution outcome")),
+ };
+ self.advance(
+ session,
+ &snapshot,
+ Command::Settle {
+ execution_id: execution.id.clone(),
+ invocation: observation.receipt.invocation,
+ outcome,
+ },
+ )
+ .await?;
+ Ok(false)
+ }
+}
+
+pub(super) fn identity(value: &impl serde::Serialize) -> Result<String, Error> {
+ Ok(format!(
+ "{:x}",
+ Sha256::digest(serde_json::to_vec(value).map_err(invalid)?)
+ ))
+}
+
+pub(super) fn now() -> Result<u64, Error> {
+ u64::try_from(
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_err(invalid)?
+ .as_millis(),
+ )
+ .map_err(invalid)
+}
+
+fn bounded(value: &str) -> String {
+ let value = value.replace('\0', "");
+ let mut end = value.len().min(1024);
+ while !value.is_char_boundary(end) {
+ end -= 1;
+ }
+ let value = value[..end].trim();
+ if value.is_empty() {
+ "Host execution interrupted".into()
+ } else {
+ value.into()
+ }
+}
diff --git a/crates/assistant/src/plan/plugin.rs b/crates/assistant/src/plan/plugin.rs
new file mode 100644
index 0000000000..a43b927b44
--- /dev/null
+++ b/crates/assistant/src/plan/plugin.rs
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod behavior;
+mod remote;
+mod tools;
+
+use super::owner::Owner;
+use futures_util::future::BoxFuture;
+use maka_plugins::{
+ composition::Scope,
+ contributions::Staged,
+ kernel::{Plugin, PluginContext},
+ session::SessionBehavior,
+};
+use serde_json::Value;
+use std::sync::Arc;
+
+pub const ID: &str = "maka.plan";
+pub const PLANNING: &str = "default:plan";
+pub const EXECUTION: &str = "maka.plan.execute";
+
+pub struct Builtin;
+impl Plugin for Builtin {
+ fn supports_scope(&self, scope: &Scope) -> bool {
+ *scope == Scope::Profile
+ }
+ fn validate(&self, _: &Scope, config: &Value) -> Result<(), maka_plugins::Error> {
+ if config.is_null() || config.as_object().is_some_and(|value| value.is_empty()) {
+ Ok(())
+ } else {
+ Err(maka_plugins::Error::Invalid(
+ "Plan takes no instance configuration".into(),
+ ))
+ }
+ }
+ fn activate(
+ &self,
+ context: PluginContext,
+ _: Value,
+ ) -> BoxFuture<'static, Result<Staged, String>> {
+ Box::pin(async move {
+ let identity = context.lifecycle.identity().map_err(message)?;
+ let owner = Owner::new(
+ context
+ .host
+ .ok_or("Plan requires public Host capabilities")?,
+ identity.entry_id,
+ );
+ let stop = context.lifecycle.stopping().map_err(message)?;
+ context
+ .lifecycle
+ .spawn("Plan settlement", owner.clone().run(stop))
+ .map_err(message)?;
+ let mut staged = Staged::default();
+ staged
+ .insert(
+ "plan-work",
+ owner.clone() as Arc<dyn maka_plugins::background::BackgroundWork>,
+ )
+ .map_err(message)?;
+ staged
+ .insert(PLANNING, SessionBehavior(Arc::new(behavior::Planning)))
+ .map_err(message)?;
+ staged
+ .insert(
+ EXECUTION,
+ SessionBehavior(Arc::new(behavior::Execute(owner.clone()))),
+ )
+ .map_err(message)?;
+ tools::publish(owner.clone(), &mut staged)?;
+ remote::publish(owner, &identity.package_id, &mut staged)?;
+ Ok(staged)
+ })
+ }
+}
+
+fn message(error: impl std::fmt::Display) -> String {
+ error.to_string()
+}
diff --git a/crates/assistant/src/plan/plugin/behavior.rs b/crates/assistant/src/plan/plugin/behavior.rs
new file mode 100644
index 0000000000..9f3a259023
--- /dev/null
+++ b/crates/assistant/src/plan/plugin/behavior.rs
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::{Phase, owner::Owner};
+use futures_util::future::BoxFuture;
+use maka_plugins::session::{Behavior, Preparation, Request};
+use maka_runtime::execution::SandboxMode;
+use std::{collections::BTreeSet, sync::Arc};
+
+pub(super) struct Planning;
+impl Behavior for Planning {
+ fn prepare(&self, request: Request) -> BoxFuture<'_, Result<Preparation, String>> {
+ Box::pin(async move {
+ let full = request.session.sandbox_mode == SandboxMode::DangerFullAccess;
+ let mut tools: BTreeSet<String> = [
+ "Read",
+ "Grep",
+ "Glob",
+ "AskUserQuestion",
+ "SubmitPlan",
+ "WebFetch",
+ "WebSearch",
+ "Recall",
+ "RecallMore",
+ ]
+ .into_iter()
+ .map(str::to_owned)
+ .collect();
+ if full {
+ tools.extend(["Write", "Edit", "apply_patch", "Shell"].map(str::to_owned));
+ }
+ let boundary = if full {
+ "Full access is enabled. Only perform side effects explicitly requested by the user; full access does not itself approve implementation."
+ } else {
+ "Inspect and discuss only. Workspace mutation, Shell, subagents and autonomous workflows are unavailable."
+ };
+ Ok(Preparation {
+ instructions: format!(
+ "You are planning a change for user approval. {boundary} Ask focused questions when needed. When ready, call SubmitPlan once with concrete ordered steps and material risks. Submission ends this planning Turn; it does not start implementation."
+ ),
+ tool_ceiling: Some(tools),
+ ..Default::default()
+ })
+ })
+ }
+}
+
+pub(super) struct Execute(pub Arc<Owner>);
+impl Behavior for Execute {
+ fn prepare(&self, request: Request) -> BoxFuture<'_, Result<Preparation, String>> {
+ Box::pin(async move {
+ let snapshot = self
+ .0
+ .repository(&request.session.session_id)
+ .map_err(super::message)?
+ .current()
+ .await
+ .map_err(super::message)?;
+ let execution = snapshot.execution.ok_or("No approved Plan execution")?;
+ if !matches!(
+ execution.phase,
+ Phase::AwaitingAdmission | Phase::Active { .. }
+ ) || execution.cancellation.is_some()
+ {
+ return Err("Plan execution is not active".into());
+ }
+ if request
+ .session
+ .bound_tools
+ .as_ref()
+ .is_some_and(|tools| !tools.contains("update_plan"))
+ {
+ return Err("Session tool ceiling excludes update_plan".into());
+ }
+ Ok(Preparation {
+ instructions: "Execute the frozen user-approved Plan in the opening message. Use update_plan to report every step with the exact IDs, at most one in progress. Mark completed only with evidence. Report interruptions truthfully. Use cancel_plan only when the user asks to abandon execution. Model-reported completion takes effect only after this Host execution ends successfully.".into(),
+ ..Default::default()
+ })
+ })
+ }
+}
diff --git a/crates/assistant/src/plan/plugin/remote.rs b/crates/assistant/src/plan/plugin/remote.rs
new file mode 100644
index 0000000000..a2915810cc
--- /dev/null
+++ b/crates/assistant/src/plan/plugin/remote.rs
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::{
+ Command, Error as PlanError, Phase, Progress, ProposalStatus, Request, Snapshot, owner::Owner,
+};
+use futures_util::future::BoxFuture;
+use maka_plugins::{
+ authorization::{Capability, Id, Request as Authorization, Target},
+ contributions::Staged,
+ remote::{Caller, Endpoint, Error, Handler, Method, key},
+};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use std::sync::Arc;
+
+#[derive(Deserialize)]
+#[serde(
+ tag = "kind",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase",
+ deny_unknown_fields
+)]
+enum Input {
+ Read,
+ History {
+ through_revision: Option<u64>,
+ #[serde(default)]
+ after: u64,
+ },
+ Artifact {
+ revision: Option<u64>,
+ source: ArtifactSource,
+ },
+ Control {
+ operation_id: String,
+ expected_revision: u64,
+ action: Control,
+ },
+}
+#[derive(Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum ArtifactSource {
+ Proposal,
+ Execution,
+}
+#[derive(Deserialize)]
+#[serde(
+ tag = "kind",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase",
+ deny_unknown_fields
+)]
+enum Control {
+ Approve {
+ proposal_id: String,
+ proposal_revision: u64,
+ grant: Id,
+ },
+ Revise {
+ proposal_id: String,
+ },
+ Abandon {
+ proposal_id: String,
+ },
+ Resume {
+ execution_id: String,
+ grant: Id,
+ },
+ Reconcile {
+ execution_id: String,
+ grant: Id,
+ },
+ Cancel {
+ execution_id: String,
+ reason: String,
+ grant: Option<Id>,
+ },
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct View {
+ revision: u64,
+ proposal: Option<Proposal>,
+ execution: Option<Execution>,
+}
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Proposal {
+ id: String,
+ plan_id: String,
+ revision: u64,
+ turn_id: String,
+ status: ProposalStatus,
+ supersedes: Option<String>,
+ source_execution_id: Option<String>,
+}
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Execution {
+ id: String,
+ proposal_id: String,
+ steps: Vec<Progress>,
+ phase: Phase,
+ cancellation: Option<String>,
+ updated_at: u64,
+}
+impl From<Snapshot> for View {
+ fn from(snapshot: Snapshot) -> Self {
+ Self {
+ revision: snapshot.revision,
+ proposal: snapshot.proposal.map(|proposal| Proposal {
+ id: proposal.id,
+ plan_id: proposal.plan_id,
+ revision: proposal.revision,
+ turn_id: proposal.turn_id,
+ status: proposal.status,
+ supersedes: proposal.supersedes,
+ source_execution_id: proposal.source_execution_id,
+ }),
+ execution: snapshot.execution.map(|execution| Execution {
+ id: execution.id,
+ proposal_id: execution.proposal_id,
+ steps: execution.steps,
+ phase: execution.phase,
+ cancellation: execution.cancellation,
+ updated_at: execution.updated_at,
+ }),
+ }
+ }
+}
+
+pub(super) fn publish(owner: Arc<Owner>, package: &str, staged: &mut Staged) -> Result<(), String> {
+ staged
+ .insert(
+ key(package, "manage").map_err(super::message)?,
+ Endpoint::standalone(Handler::Method(Arc::new(Service(owner)))),
+ )
+ .map_err(super::message)
+}
+struct Service(Arc<Owner>);
+impl Method for Service {
+ fn call(&self, input: Value, caller: Caller) -> BoxFuture<'static, Result<Value, Error>> {
+ let owner = self.0.clone();
+ Box::pin(async move {
+ let input: Input =
+ serde_json::from_value(input).map_err(|error| Error::Invalid(error.to_string()))?;
+ let session = caller
+ .session_id
+ .as_deref()
+ .ok_or_else(|| Error::Invalid("Bind Plan to a Session".into()))?;
+ caller.views.session().await?;
+ let repo = owner.repository(session).map_err(failed)?;
+ let value = match input {
+ Input::Read => json!(View::from(repo.current().await.map_err(failed)?)),
+ Input::Artifact { revision, source } => {
+ let snapshot = match revision {
+ Some(revision) => repo.at(revision).await,
+ None => repo.current().await,
+ }
+ .map_err(failed)?;
+ let artifact = match source {
+ ArtifactSource::Proposal => {
+ snapshot.proposal.map(|proposal| proposal.artifact)
+ }
+ ArtifactSource::Execution => {
+ snapshot.execution.map(|execution| execution.artifact)
+ }
+ };
+ json!({ "revision": snapshot.revision, "artifact": artifact })
+ }
+ Input::History {
+ through_revision,
+ after,
+ } => {
+ let current = repo.current().await.map_err(failed)?.revision;
+ let through = through_revision.unwrap_or(current);
+ if after > through || through > current {
+ return Err(Error::Invalid(
+ "Plan history cursor is outside committed revisions".into(),
+ ));
+ }
+ let snapshot = if after < through {
+ Some(View::from(repo.at(after + 1).await.map_err(failed)?))
+ } else {
+ None
+ };
+ json!({
+ "throughRevision": through,
+ "snapshots": snapshot.into_iter().collect::<Vec<_>>(),
+ "nextAfter": (after + 1 < through).then_some(after + 1),
+ })
+ }
+ Input::Control {
+ operation_id,
+ expected_revision,
+ action,
+ } => {
+ let owned = caller
+ .views
+ .authorize(Authorization {
+ operation_id: uuid::Uuid::new_v4(),
+ title: "Control Session Plan".into(),
+ target: Target::Session {
+ session_id: session.into(),
+ },
+ capabilities: [Capability::Executions].into(),
+ })
+ .await?;
+ let result = async {
+ let commands = owner.executions.acquire(owned.scope()).await?;
+ commands.session(session.into()).await?;
+ let command = match action {
+ Control::Approve {
+ proposal_id,
+ proposal_revision,
+ grant,
+ } => Command::Approve {
+ proposal_id,
+ proposal_revision,
+ behavior: super::EXECUTION
+ .to_owned()
+ .try_into()
+ .map_err(super::super::invalid)?,
+ grant,
+ },
+ Control::Revise { proposal_id } => Command::Revise { proposal_id },
+ Control::Abandon { proposal_id } => Command::Abandon { proposal_id },
+ Control::Resume {
+ execution_id,
+ grant,
+ } => Command::Resume {
+ execution_id,
+ grant,
+ },
+ Control::Reconcile {
+ execution_id,
+ grant,
+ } => Command::Reconcile {
+ execution_id,
+ grant,
+ },
+ Control::Cancel {
+ execution_id,
+ reason,
+ grant,
+ } => Command::Cancel {
+ execution_id,
+ reason,
+ grant,
+ },
+ };
+ let request = Request {
+ operation_id,
+ expected_revision,
+ command,
+ };
+ // Reading an accepted decision requires current Session
+ // access, not renewal of its former background grant.
+ if let Some(receipt) = repo.receipt(&request).await? {
+ return Ok(receipt);
+ }
+ match &request.command {
+ Command::Approve { grant, .. }
+ | Command::Resume { grant, .. }
+ | Command::Reconcile { grant, .. }
+ | Command::Cancel {
+ grant: Some(grant), ..
+ } => owner.grant(*grant, session).await?,
+ _ => {}
+ }
+ owner.apply(session, &request).await
+ }
+ .await;
+ owned
+ .finish()
+ .await
+ .map_err(|_| Error::CleanupUnconfirmed)?;
+ json!(View::from(result.map_err(failed)?))
+ }
+ };
+ maka_plugins::remote::validate_payload(&value)?;
+ Ok(value)
+ })
+ }
+}
+fn failed(error: PlanError) -> Error {
+ match error {
+ PlanError::Storage(maka_plugins::storage::StoreError::OutcomeUnknown(message))
+ | PlanError::Execution(maka_plugins::execution::CommandError::OutcomeUnknown(message)) => {
+ Error::OutcomeUnknown(message)
+ }
+ other => Error::Provider(other.to_string()),
+ }
+}
diff --git a/crates/assistant/src/plan/plugin/tools.rs b/crates/assistant/src/plan/plugin/tools.rs
new file mode 100644
index 0000000000..9726d1b5f2
--- /dev/null
+++ b/crates/assistant/src/plan/plugin/tools.rs
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::super::{
+ Artifact, Command, Error, Progress, Request,
+ owner::{Owner, identity},
+};
+use maka_plugins::contributions::Staged;
+use maka_runtime::{
+ tool_call::ToolRejection,
+ tools::{
+ PreparationFuture, PreparedEffect, ToolCallContext, ToolDefinition, ToolError, ToolHandler,
+ ToolNesting, ToolPreparer, ToolRegistration, ToolSemantics,
+ },
+};
+use maka_tool_catalog::plugins::{Binding, BindingProvider, BindingRequest, PluginTool};
+use serde::Deserialize;
+use serde_json::{Value, json};
+use std::sync::Arc;
+use tokio_util::sync::CancellationToken;
+
+#[derive(Clone, Copy)]
+enum Action {
+ Propose,
+ Progress,
+ Cancel,
+}
+impl Action {
+ fn name(self) -> &'static str {
+ match self {
+ Self::Propose => "SubmitPlan",
+ Self::Progress => "update_plan",
+ Self::Cancel => "cancel_plan",
+ }
+ }
+}
+
+#[derive(Deserialize, schemars::JsonSchema)]
+#[serde(deny_unknown_fields)]
+struct Update {
+ steps: Vec<Progress>,
+}
+#[derive(Deserialize, schemars::JsonSchema)]
+#[serde(deny_unknown_fields)]
+struct Cancel {
+ reason: String,
+}
+enum Input {
+ Propose(Artifact),
+ Progress(Vec<Progress>),
+ Cancel(String),
+}
+#[derive(Clone)]
+struct Tool {
+ owner: Arc<Owner>,
+ action: Action,
+}
+
+pub(super) fn publish(owner: Arc<Owner>, staged: &mut Staged) -> Result<(), String> {
+ for action in [Action::Propose, Action::Progress, Action::Cancel] {
+ let handler = Arc::new(Tool {
+ owner: owner.clone(),
+ action,
+ });
+ let (description, input_schema) = match action {
+ Action::Propose => (
+ "Submit the finished Plan for user approval and end this planning Turn. This does not approve or start execution.",
+ schemars::schema_for!(Artifact).into(),
+ ),
+ Action::Progress => (
+ "Report progress for this exact approved Plan execution. Include every step, with at most one in_progress. Completion is recorded only after Host execution succeeds.",
+ schemars::schema_for!(Update).into(),
+ ),
+ Action::Cancel => (
+ "Request cancellation of this Plan when the user explicitly asks to abandon it. Ends this Turn; Host settles only the original execution.",
+ schemars::schema_for!(Cancel).into(),
+ ),
+ };
+ let tool = PluginTool::new(ToolRegistration {
+ definition: ToolDefinition {
+ provider: None,
+ name: action.name().into(),
+ description: description.into(),
+ input_schema,
+ },
+ nesting: ToolNesting::DirectOnly,
+ semantics: match action {
+ Action::Progress => ToolSemantics::ExclusiveStep,
+ _ => ToolSemantics::FinishTurn,
+ },
+ handler: ToolHandler::Prepared(handler.clone()),
+ })
+ .map_err(super::message)?
+ .with_binding(handler)
+ .always_visible();
+ staged.insert(action.name(), tool).map_err(super::message)?;
+ }
+ Ok(())
+}
+
+impl BindingProvider for Tool {
+ fn bind(
+ &self,
+ request: BindingRequest,
+ _: maka_plugins::filesystem::ReadDirectory,
+ ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<Binding>, ToolError>> + Send>> {
+ let expected = match self.action {
+ Action::Propose => super::PLANNING,
+ _ => super::EXECUTION,
+ };
+ let matches = request
+ .behavior
+ .as_ref()
+ .is_some_and(|behavior| behavior.as_str() == expected);
+ let handler = self.clone();
+ Box::pin(async move {
+ if !matches {
+ return Ok(None);
+ }
+ let snapshot = handler
+ .owner
+ .repository(&request.invocation.session_id)
+ .map_err(failed)?
+ .current()
+ .await
+ .map_err(failed)?;
+ let context = match handler.action {
+ Action::Propose => snapshot.proposal.as_ref().map(|proposal| {
+ json!({
+ "proposalId": proposal.id,
+ "revision": proposal.revision,
+ "status": proposal.status,
+ "plan": proposal.artifact,
+ })
+ }),
+ Action::Progress => snapshot.execution.as_ref().map(|execution| {
+ json!({
+ "executionId": execution.id,
+ "plan": execution.artifact,
+ "progress": execution.steps,
+ "cancellationRequested": execution.cancellation.is_some(),
+ })
+ }),
+ Action::Cancel => None,
+ }
+ .map(|context| format!("Current Plan state for this model step:\n{context}"));
+ Ok(Some(Binding {
+ provider_tools: Default::default(),
+ handler: Some(Arc::new(handler)),
+ context,
+ }))
+ })
+ }
+}
+
+impl ToolPreparer for Tool {
+ fn names(&self) -> Vec<String> {
+ vec![self.action.name().into()]
+ }
+ fn prepare(
+ &self,
+ name: String,
+ input: Value,
+ context: ToolCallContext,
+ cancellation: CancellationToken,
+ ) -> PreparationFuture {
+ let owner = self.owner.clone();
+ let action = self.action;
+ Box::pin(async move {
+ if cancellation.is_cancelled() {
+ return Err(ToolRejection::Cancelled);
+ }
+ if name != action.name() {
+ return Err(ToolRejection::Unavailable);
+ }
+ let input = match action {
+ Action::Propose => {
+ let artifact: Artifact = serde_json::from_value(input).map_err(invalid)?;
+ artifact.validate().map_err(invalid)?;
+ Input::Propose(artifact)
+ }
+ Action::Progress => Input::Progress(
+ serde_json::from_value::<Update>(input)
+ .map_err(invalid)?
+ .steps,
+ ),
+ Action::Cancel => Input::Cancel(
+ serde_json::from_value::<Cancel>(input)
+ .map_err(invalid)?
+ .reason,
+ ),
+ };
+ Ok(PreparedEffect::new(move |_| {
+ Box::pin(async move {
+ let call = maka_plugins::call::current().ok_or_else(|| {
+ ToolError::Failed("Expected an admitted Agent call".into())
+ })?;
+ if call.identity.agent() != Some(&context.invocation) {
+ return Err(ToolError::Failed("Plan invocation identity changed".into()));
+ }
+ let commands = owner.executions.acquire(call).await?;
+ let session = &context.invocation.session_id;
+ let (snapshot, command) = match input {
+ Input::Propose(artifact) => {
+ let activity = commands.activity(session.clone()).await?;
+ if !activity.execution.is_some_and(|current| {
+ current.invocation == context.invocation
+ && current.behavior.is_some_and(|behavior| {
+ behavior.as_str() == super::PLANNING
+ })
+ }) {
+ return Err(ToolError::Failed(
+ "SubmitPlan requires an active planning behavior".into(),
+ ));
+ }
+ let snapshot = owner
+ .repository(session)
+ .map_err(failed)?
+ .current()
+ .await
+ .map_err(failed)?;
+ (
+ snapshot,
+ Command::Propose {
+ turn_id: context.invocation.turn_id.clone(),
+ artifact,
+ },
+ )
+ }
+ Input::Progress(steps) => {
+ let (snapshot, receipt) = owner
+ .active(commands.as_ref(), &context.invocation)
+ .await
+ .map_err(failed)?;
+ let execution_id = snapshot.execution.as_ref().unwrap().id.clone();
+ (
+ snapshot,
+ Command::Progress {
+ execution_id,
+ invocation: receipt.invocation,
+ steps,
+ },
+ )
+ }
+ Input::Cancel(reason) => {
+ let (snapshot, _) = owner
+ .active(commands.as_ref(), &context.invocation)
+ .await
+ .map_err(failed)?;
+ let execution_id = snapshot.execution.as_ref().unwrap().id.clone();
+ (
+ snapshot,
+ Command::Cancel {
+ execution_id,
+ reason,
+ grant: None,
+ },
+ )
+ }
+ };
+ let request = Request {
+ operation_id: identity(&(
+ &context.invocation,
+ &context.operation_id,
+ &name,
+ ))
+ .map_err(failed)?,
+ expected_revision: snapshot.revision,
+ command,
+ };
+ let snapshot = owner.apply(session, &request).await.map_err(failed)?;
+ Ok(json!({
+ "revision": snapshot.revision,
+ "proposalId": snapshot.proposal.as_ref().map(|proposal| &proposal.id),
+ "executionId": snapshot.execution.as_ref().map(|execution| &execution.id),
+ "recorded": true,
+ })
+ .into())
+ })
+ }))
+ })
+ }
+}
+fn invalid(error: impl std::fmt::Display) -> ToolRejection {
+ ToolRejection::InvalidInput {
+ message: error.to_string(),
+ }
+}
+fn failed(error: Error) -> ToolError {
+ match error {
+ Error::Storage(maka_plugins::storage::StoreError::OutcomeUnknown(message))
+ | Error::Execution(maka_plugins::execution::CommandError::OutcomeUnknown(message)) => {
+ ToolError::OutcomeUnknown(message)
+ }
+ other => ToolError::Failed(other.to_string()),
+ }
+}
diff --git a/crates/assistant/src/plan/repository.rs b/crates/assistant/src/plan/repository.rs
index 3d80a6f35a..20293bd036 100644
--- a/crates/assistant/src/plan/repository.rs
+++ b/crates/assistant/src/plan/repository.rs
@@ -29,6 +29,7 @@ pub struct Repository {
storage: Arc<dyn Store>,
prefix: String,
session: String,
+ index: String,
}
#[derive(Serialize, Deserialize)]
@@ -72,6 +73,7 @@ impl Repository {
storage,
session: session.into(),
prefix: format!("plans/{}/", digest(&(entry, session))?),
+ index: format!("plan-sessions/{}/{session}", digest(&entry)?),
})
}
@@ -133,7 +135,7 @@ impl Repository {
fingerprint: digest(request)?,
snapshot: snapshot.clone(),
};
- let writes = vec![
+ let mut writes = vec![
mutation(format!("{}head", self.prefix), expected, &head)?,
mutation(self.decision_key(snapshot.revision), None, &decision)?,
mutation(
@@ -142,6 +144,9 @@ impl Repository {
&snapshot.revision,
)?,
];
+ if expected.is_none() {
+ writes.push(mutation(self.index.clone(), None, &self.session)?);
+ }
match self.storage.batch(writes).await {
Ok(_) => Ok(snapshot),
Err(StoreError::Conflict { .. }) => {
@@ -153,6 +158,35 @@ impl Repository {
}
}
+ pub async fn sessions(storage: &dyn Store, entry: &str) -> Result<Vec<String>, Error> {
+ let prefix = format!("plan-sessions/{}/", digest(&entry)?);
+ let mut after = None;
+ let mut sessions = Vec::new();
+ loop {
+ let page = storage
+ .scan(maka_plugins::storage::Scan {
+ prefix: prefix.clone(),
+ after,
+ })
+ .await?;
+ for item in page.entries {
+ let session: String = decode(item.record)?;
+ identifier(&session)?;
+ sessions.push(session);
+ }
+ after = page.next_after;
+ if after.is_none() {
+ return Ok(sessions);
+ }
+ }
+ }
+
+ pub async fn at(&self, revision: u64) -> Result<Snapshot, Error> {
+ self.decision(revision)
+ .await
+ .map(|decision| decision.snapshot)
+ }
+
/// A bounded historical read, not a live projection assembled across writes.
pub async fn history(
&self,
diff --git a/crates/assistant/src/plan/transition.rs b/crates/assistant/src/plan/transition.rs
index ff7bd91665..506818a32a 100644
--- a/crates/assistant/src/plan/transition.rs
+++ b/crates/assistant/src/plan/transition.rs
@@ -89,6 +89,7 @@ impl Snapshot {
proposal_id,
proposal_revision,
behavior,
+ grant,
} => {
let proposal = self
.proposal
@@ -132,13 +133,19 @@ impl Snapshot {
orchestration_mode: Some(behavior.clone()),
},
phase: Phase::AwaitingAdmission,
+ grant: *grant,
+ dispatched: false,
+ cancellation: None,
updated_at: now,
};
execution.freeze_request()?;
proposal.status = ProposalStatus::Approved;
self.execution = Some(execution);
}
- Command::Resume { execution_id } => {
+ Command::Resume {
+ execution_id,
+ grant,
+ } => {
let execution = self.execution(execution_id)?;
if !matches!(execution.phase, Phase::Interrupted { .. }) {
return Err(Error::Conflict);
@@ -152,6 +159,48 @@ impl Snapshot {
execution.request.operation_id = format!("plan_{operation}");
execution.freeze_request()?;
execution.phase = Phase::AwaitingAdmission;
+ execution.grant = *grant;
+ execution.dispatched = false;
+ execution.cancellation = None;
+ execution.updated_at = now;
+ }
+ Command::Dispatch { execution_id } => {
+ let execution = self.execution_mut(execution_id)?;
+ if !matches!(execution.phase, Phase::AwaitingAdmission)
+ || execution.dispatched
+ || execution.cancellation.is_some()
+ {
+ return Err(Error::Conflict);
+ }
+ execution.dispatched = true;
+ execution.updated_at = now;
+ }
+ Command::Reconcile {
+ execution_id,
+ grant,
+ } => {
+ let execution = self.execution_mut(execution_id)?;
+ if !matches!(
+ execution.phase,
+ Phase::AwaitingAdmission | Phase::Active { .. }
+ ) {
+ return Err(Error::Conflict);
+ }
+ execution.grant = *grant;
+ execution.updated_at = now;
+ }
+ Command::Defer { execution_id } => {
+ let execution = self.execution_mut(execution_id)?;
+ if !matches!(execution.phase, Phase::AwaitingAdmission) {
+ return Err(Error::Conflict);
+ }
+ execution.dispatched = false;
+ if let Some(reason) = &execution.cancellation {
+ execution.phase = Phase::Cancelled {
+ receipt: None,
+ reason: reason.clone(),
+ };
+ }
execution.updated_at = now;
}
Command::Accept {
@@ -190,9 +239,15 @@ impl Snapshot {
{
return Err(Error::Conflict);
}
- execution.phase = Phase::Interrupted {
- receipt: None,
- reason: reason.clone(),
+ execution.phase = match &execution.cancellation {
+ Some(reason) => Phase::Cancelled {
+ receipt: None,
+ reason: reason.clone(),
+ },
+ None => Phase::Interrupted {
+ receipt: None,
+ reason: reason.clone(),
+ },
};
execution.updated_at = now;
}
@@ -202,16 +257,13 @@ impl Snapshot {
steps,
} => {
let execution = self.execution_mut(execution_id)?;
- let receipt = execution.active(invocation)?.clone();
+ execution.active(invocation)?;
+ if execution.cancellation.is_some() {
+ return Err(Error::Conflict);
+ }
execution.artifact.progress(steps)?;
execution.steps = steps.clone();
execution.updated_at = now;
- if steps
- .iter()
- .all(|s| matches!(s.status, StepStatus::Completed | StepStatus::Skipped))
- {
- execution.phase = Phase::Completed { receipt };
- }
}
Command::Interrupt {
execution_id,
@@ -230,18 +282,30 @@ impl Snapshot {
Command::Cancel {
execution_id,
reason,
+ grant,
} => {
text(reason, 1024)?;
let execution = self.execution_mut(execution_id)?;
- let Phase::Interrupted { receipt, .. } = &execution.phase else {
- // Resolve uncertain admission or stop the exact Host invocation
- // first. A domain mutation cannot undo already accepted work.
- return Err(Error::Conflict);
- };
- execution.phase = Phase::Cancelled {
- receipt: receipt.clone(),
- reason: reason.clone(),
+ match &execution.phase {
+ Phase::Interrupted { receipt, .. } => {
+ execution.phase = Phase::Cancelled {
+ receipt: receipt.clone(),
+ reason: reason.clone(),
+ };
+ }
+ Phase::AwaitingAdmission if !execution.dispatched => {
+ execution.phase = Phase::Cancelled {
+ receipt: None,
+ reason: reason.clone(),
+ };
+ }
+ Phase::AwaitingAdmission | Phase::Active { .. } => {}
+ _ => return Err(Error::Conflict),
};
+ execution.cancellation = Some(reason.clone());
+ if let Some(grant) = grant {
+ execution.grant = *grant;
+ }
execution.updated_at = now;
if let Some(proposal) = &mut self.proposal
&& proposal.source_execution_id.as_ref() == Some(execution_id)
@@ -250,6 +314,44 @@ impl Snapshot {
proposal.status = ProposalStatus::RevisionRequested;
}
}
+ Command::Settle {
+ execution_id,
+ invocation,
+ outcome,
+ } => {
+ let execution = self.execution_mut(execution_id)?;
+ let receipt = execution.active(invocation)?.clone();
+ execution.phase = if let Some(reason) = &execution.cancellation {
+ Phase::Cancelled {
+ receipt: Some(receipt),
+ reason: reason.clone(),
+ }
+ } else {
+ match outcome {
+ Settlement::Completed
+ if execution.steps.iter().all(|step| {
+ matches!(step.status, StepStatus::Completed | StepStatus::Skipped)
+ }) =>
+ {
+ Phase::Completed { receipt }
+ }
+ Settlement::Completed => Phase::Interrupted {
+ receipt: Some(receipt),
+ reason:
+ "Host execution ended before every Plan step was reported complete"
+ .into(),
+ },
+ Settlement::Interrupted { reason } => {
+ text(reason, 1024)?;
+ Phase::Interrupted {
+ receipt: Some(receipt),
+ reason: reason.clone(),
+ }
+ }
+ }
+ };
+ execution.updated_at = now;
+ }
}
Ok(())
}
diff --git a/crates/assistant/tests/plan.rs b/crates/assistant/tests/plan.rs
index 9f5a279161..9fc2bfd879 100644
--- a/crates/assistant/tests/plan.rs
+++ b/crates/assistant/tests/plan.rs
@@ -51,6 +51,7 @@ async fn plan_receipts_survive_lost_replies_restart_and_competing_approvals() {
assert_eq!(proposed.proposal.as_ref().unwrap().submitted_at, 1);
let proposal = proposed.proposal.as_ref().unwrap();
let approval = Command::Approve {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
proposal_id: proposal.id.clone(),
proposal_revision: proposal.revision,
behavior: "example.plan.execute".to_owned().try_into().unwrap(),
@@ -87,6 +88,7 @@ async fn plan_receipts_survive_lost_replies_restart_and_competing_approvals() {
"approve_a",
1,
Command::Approve {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
proposal_id: proposal.id,
proposal_revision: 1,
behavior: "example.plan.execute".to_owned().try_into().unwrap(),
@@ -162,6 +164,7 @@ async fn plan_receipts_survive_lost_replies_restart_and_competing_approvals() {
"resume",
interrupted.revision,
Command::Resume {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
execution_id: id.clone(),
},
),
@@ -217,14 +220,33 @@ async fn plan_receipts_survive_lost_replies_restart_and_competing_approvals() {
&request(
"complete",
active.revision,
- progress(next_receipt.invocation),
+ progress(next_receipt.invocation.clone()),
),
10,
)
.await
.unwrap();
assert!(matches!(
- completed.execution.unwrap().phase,
+ completed.execution.as_ref().unwrap().phase,
+ Phase::Active { .. }
+ ));
+ let settled = repository
+ .apply(
+ &request(
+ "settle",
+ completed.revision,
+ Command::Settle {
+ execution_id: id,
+ invocation: next_receipt.invocation,
+ outcome: maka_assistant::plan::Settlement::Completed,
+ },
+ ),
+ 11,
+ )
+ .await
+ .unwrap();
+ assert!(matches!(
+ settled.execution.unwrap().phase,
Phase::Completed { .. }
));
assert_eq!(repository.apply(&accept, 11).await.unwrap().revision, 3);
diff --git a/crates/assistant/tests/plan_history.rs b/crates/assistant/tests/plan_history.rs
index ce84faf49f..6ea4f78447 100644
--- a/crates/assistant/tests/plan_history.rs
+++ b/crates/assistant/tests/plan_history.rs
@@ -36,6 +36,7 @@ async fn advance(repository: &Repository, state: &mut Snapshot, id: &str, comman
fn approval(state: &Snapshot) -> Command {
let p = state.proposal.as_ref().unwrap();
Command::Approve {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
proposal_id: p.id.clone(),
proposal_revision: p.revision,
behavior: Default::default(),
@@ -101,6 +102,7 @@ async fn revisions_replanning_and_admission_recovery_do_not_reuse_stale_authorit
&repository,
&state,
Command::Approve {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
proposal_id: old.id,
proposal_revision: old.revision,
behavior: Default::default(),
@@ -134,13 +136,10 @@ async fn revisions_replanning_and_admission_recovery_do_not_reuse_stale_authorit
advance(&repository, &mut state, "approve", approve).await;
let execution = state.execution.clone().unwrap();
let id = execution.id.clone();
- // An unknown admission cannot be cancelled, replanned, or assigned a new request.
+ // Pending admission cannot be replanned or assigned a new request.
for command in [
- Command::Cancel {
- execution_id: id.clone(),
- reason: "cancel".into(),
- },
Command::Resume {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
execution_id: id.clone(),
},
Command::Propose {
@@ -189,6 +188,7 @@ async fn revisions_replanning_and_admission_recovery_do_not_reuse_stale_authorit
&repository,
&state,
Command::Resume {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
execution_id: id.clone(),
},
)
@@ -198,6 +198,7 @@ async fn revisions_replanning_and_admission_recovery_do_not_reuse_stale_authorit
&mut state,
"cancel_source",
Command::Cancel {
+ grant: None,
execution_id: id,
reason: "withdraw original work".into(),
},
@@ -258,6 +259,7 @@ async fn revisions_replanning_and_admission_recovery_do_not_reuse_stale_authorit
&mut state,
"resume",
Command::Resume {
+ grant: maka_plugins::authorization::Id(uuid::Uuid::from_u128(1)),
execution_id: id.clone(),
},
)
diff --git a/crates/runtime-host/src/execution/prepare/environment.rs b/crates/runtime-host/src/execution/prepare/environment.rs
index 2b4a9e9f10..78b3cc1f70 100644
--- a/crates/runtime-host/src/execution/prepare/environment.rs
+++ b/crates/runtime-host/src/execution/prepare/environment.rs
@@ -90,6 +90,11 @@ impl Executions {
let mut session = record.configuration;
if let Some(mode) = &orchestration {
session.orchestration_mode = mode.clone();
+ } else {
+ session.orchestration_mode = session
+ .orchestration_mode
+ .for_collaboration(session.collaboration_mode)
+ .map_err(internal)?;
}
if record.archived {
return Err(failure(Code::SessionArchived, "Session is archived"));
@@ -111,13 +116,13 @@ impl Executions {
.map_err(internal)?
.map_err(internal)?;
use maka_protocol::session::CollaborationMode;
- if session.collaboration_mode != CollaborationMode::Agent {
- return Err(failure(
- Code::OperationUnavailable,
- "Plan execution is not installed",
- ));
- }
if let crate::session::SessionTarget::Executor { executor_id, .. } = &session.target {
+ if session.collaboration_mode != CollaborationMode::Agent {
+ return Err(failure(
+ Code::OperationUnavailable,
+ "Executor does not support native collaboration modes",
+ ));
+ }
let (bindings, _) = self
.capabilities
.prepare_tools(
diff --git a/crates/runtime-host/src/execution/resume.rs b/crates/runtime-host/src/execution/resume.rs
index 215b75ae21..18dfa24232 100644
--- a/crates/runtime-host/src/execution/resume.rs
+++ b/crates/runtime-host/src/execution/resume.rs
@@ -265,13 +265,6 @@ impl Executions {
"Cannot resume an archived Session",
));
}
- use maka_protocol::session::CollaborationMode;
- if session.configuration.collaboration_mode != CollaborationMode::Agent {
- return Err(failure(
- Code::OperationUnavailable,
- "This execution mode does not support resume",
- ));
- }
Ok(session.configuration)
}
diff --git a/crates/runtime-host/src/plugins.rs b/crates/runtime-host/src/plugins.rs
index 0eb588c4ad..d01bad0f27 100644
--- a/crates/runtime-host/src/plugins.rs
+++ b/crates/runtime-host/src/plugins.rs
@@ -34,6 +34,7 @@ pub(crate) mod javascript;
pub(crate) mod jev;
pub(crate) mod models;
mod owner;
+pub(crate) mod plan;
mod pricing;
mod process;
pub(crate) mod recall;
diff --git a/crates/runtime-host/src/plugins/javascript/binding.rs b/crates/runtime-host/src/plugins/javascript/binding.rs
index 34406cae0c..d53866cb0e 100644
--- a/crates/runtime-host/src/plugins/javascript/binding.rs
+++ b/crates/runtime-host/src/plugins/javascript/binding.rs
@@ -56,7 +56,7 @@ impl BindingProvider for Provider {
let value = callbacks::invoke(
&callback.module,
callback.id,
- json!({"invocation":request.invocation, "cwd":request.cwd, "tools":request.tools, "model":request.model}),
+ json!({"invocation":request.invocation, "behavior":request.behavior, "cwd":request.cwd, "tools":request.tools, "model":request.model}),
json!({"readView":view.id}),
request.cancellation,
)
diff --git a/crates/runtime-host/src/plugins/plan.rs b/crates/runtime-host/src/plugins/plan.rs
new file mode 100644
index 0000000000..d2be08e666
--- /dev/null
+++ b/crates/runtime-host/src/plugins/plan.rs
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::Setup;
+use maka_assistant::plan::plugin::{Builtin, ID};
+use maka_plugins::{
+ composition::{Entry, Operation, Scope},
+ kernel::Definition,
+};
+use std::sync::Arc;
+
+pub(crate) fn install(setup: &mut Setup) -> Result<(), maka_plugins::Error> {
+ if setup.builtins.contains_key(ID) || setup.layers.contains_key(ID) {
+ return Err(maka_plugins::Error::Invalid(
+ "built-in Plan identity is reserved".into(),
+ ));
+ }
+ setup.builtins.insert(
+ ID.into(),
+ Arc::new(Definition {
+ id: ID.into(),
+ revision: env!("CARGO_PKG_VERSION").into(),
+ dependencies: vec![],
+ inject: vec![],
+ plugin: Arc::new(Builtin),
+ }),
+ );
+ let mut entry = Entry::new(ID)?;
+ entry.package_id = Some(ID.into());
+ setup.layers.insert(
+ ID.into(),
+ vec![Operation::Insert {
+ root_id: Some(Scope::Profile),
+ parent_id: None,
+ position: None,
+ entry,
+ }],
+ );
+ Ok(())
+}
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index f57d153805..4593a4d2da 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -278,6 +278,7 @@ impl Host {
crate::plugins::todo::install(&mut setup)?;
crate::plugins::recall::install(&mut setup)?;
crate::plugins::assistant::install(&mut setup)?;
+ crate::plugins::plan::install(&mut setup)?;
crate::plugins::models::install(&mut setup, runtime.clone())?;
if let Some(path) = global_instructions {
match maka_plugins::filesystem::ReadRoot::open(path).await {
diff --git a/crates/runtime-host/tests/integration/main.rs b/crates/runtime-host/tests/integration/main.rs
index 8b3e6bf5c2..139055bb6f 100644
--- a/crates/runtime-host/tests/integration/main.rs
+++ b/crates/runtime-host/tests/integration/main.rs
@@ -68,6 +68,7 @@ mod model_overrides;
mod model_providers;
mod oauth;
mod oauth_execution;
+mod plan_plugin;
mod plugin_authorization;
mod plugin_clients;
mod plugin_commands;
diff --git a/crates/runtime-host/tests/integration/plan_plugin.rs b/crates/runtime-host/tests/integration/plan_plugin.rs
new file mode 100644
index 0000000000..e016424695
--- /dev/null
+++ b/crates/runtime-host/tests/integration/plan_plugin.rs
@@ -0,0 +1,566 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::support::{
+ client_probe::ClientFixture,
+ message_recovery::{ModelRequest, Provider, configure},
+ peer::Peer,
+};
+use maka_runtime_host::server::{Host, local::LocalListener};
+use serde_json::{Value, json};
+use std::{collections::BTreeSet, time::Duration};
+use tokio_util::sync::CancellationToken;
+
+const SESSION: &str = "plan-session";
+
+async fn open(peer: &mut Peer) -> Value {
+ let binding = json!({"packageId":"maka.plan","method":"manage","sessionId":SESSION});
+ let bound = peer
+ .rpc("plugin.remote", json!({"kind":"bind","binding":binding}))
+ .await;
+ assert_eq!(bound["ok"], true, "{bound}");
+ let document = peer
+ .rpc("plugin.remote", json!({"kind":"open_document"}))
+ .await;
+ assert_eq!(document["ok"], true, "{document}");
+ json!({"kind":"call","binding":binding,"target":bound["result"]["target"],"document":document["result"]["document"]})
+}
+async fn call(peer: &mut Peer, envelope: &Value, input: Value) -> Value {
+ let mut request = envelope.clone();
+ request["input"] = input;
+ peer.rpc("plugin.remote", request).await
+}
+async fn read(peer: &mut Peer, envelope: &Value) -> Value {
+ let response = call(peer, envelope, json!({"kind":"read"})).await;
+ assert_eq!(response["ok"], true, "{response}");
+ response["result"]["value"].clone()
+}
+async fn wait_state(
+ peer: &mut Peer,
+ envelope: &Value,
+ predicate: impl Fn(&Value) -> bool,
+) -> Value {
+ tokio::time::timeout(Duration::from_secs(15), async {
+ loop {
+ let current = read(peer, envelope).await;
+ if predicate(¤t) {
+ return current;
+ }
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("Plan did not reach the expected durable state")
+}
+async fn turn_finished(peer: &mut Peer, turn: &str) -> Value {
+ tokio::time::timeout(Duration::from_secs(15), async {
+ loop {
+ let state = peer
+ .rpc("turn.query", json!({"sessionId":SESSION,"turnId":turn}))
+ .await;
+ assert_eq!(state["ok"], true, "{state}");
+ if matches!(
+ state["result"]["status"].as_str(),
+ Some("completed" | "failed" | "cancelled")
+ ) {
+ return state["result"].clone();
+ }
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .unwrap()
+}
+async fn start(peer: &mut Peer, turn: &str) {
+ let started = peer.rpc("turn.start", json!({
+ "sessionId":SESSION,"turnId":turn,"content":{"text":"Inspect and propose the next change"}
+ })).await;
+ assert_eq!(started["ok"], true, "{started}");
+}
+async fn close(peer: &mut Peer, envelope: &Value) {
+ let result = peer
+ .rpc(
+ "plugin.remote",
+ json!({"kind":"close_document","document":envelope["document"]}),
+ )
+ .await;
+ assert_eq!(result["ok"], true, "{result}");
+}
+async fn enable(peer: &mut Peer, enabled: bool) {
+ let result = peer
+ .rpc(
+ "plugin.composition.apply",
+ json!({
+ "operations":[{"type":"update","entryId":"maka.plan","patch":{"disabled":!enabled}}]
+ }),
+ )
+ .await;
+ assert_eq!(result["ok"], true, "{result}");
+ peer.wait_for_plugins().await;
+}
+async fn grant(peer: &mut Peer, envelope: &Value) -> Value {
+ let result = peer.rpc("plugin.authorization", json!({
+ "binding":envelope["binding"],"target":envelope["target"],
+ "command":{"kind":"approve","request":{
+ "operationId":uuid::Uuid::new_v4(),"title":"Execute this Session's approved Plan",
+ "target":{"kind":"session","sessionId":SESSION},"capabilities":["executions"]
+ }}
+ })).await;
+ assert_eq!(result["ok"], true, "{result}");
+ result["result"]["grant"]["id"].clone()
+}
+fn approval(state: &Value, grant: &Value, operation: &str) -> Value {
+ json!({"kind":"control","operationId":operation,"expectedRevision":state["revision"],
+ "action":{"kind":"approve","proposalId":state["proposal"]["id"],
+ "proposalRevision":state["proposal"]["revision"],"grant":grant}})
+}
+fn artifact() -> Value {
+ json!({"title":"Verified Plan","overview":"Inspect, implement and verify",
+ "steps":[{"id":"change","title":"Implement","description":"Make the approved change","files":[],"complexity":"low"},
+ {"id":"verify","title":"Verify","description":"Verify the behavior","files":[],"complexity":"low"}],
+ "risks":[]})
+}
+fn tool(name: &str, input: Value) -> Value {
+ json!({"index":0,"delta":{"tool_calls":[{"index":0,"id":uuid::Uuid::new_v4(),
+ "type":"function","function":{"name":name,"arguments":input.to_string()}}]},"finish_reason":"tool_calls"})
+}
+fn answer(text: &str) -> Value {
+ json!({"index":0,"delta":{"content":text},"finish_reason":"stop"})
+}
+async fn next(requests: &mut tokio::sync::mpsc::Receiver<ModelRequest>) -> ModelRequest {
+ tokio::time::timeout(Duration::from_secs(15), requests.recv())
+ .await
+ .unwrap()
+ .unwrap()
+}
+fn names(body: &Value) -> BTreeSet<&str> {
+ body["tools"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .filter_map(|tool| tool["function"]["name"].as_str())
+ .collect()
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn plan_uses_public_controls_and_recovers_settlement_without_replaying_work() {
+ tokio::time::timeout(Duration::from_secs(120), lifecycle())
+ .await
+ .unwrap();
+}
+async fn lifecycle() {
+ let fixture = ClientFixture::new("maka-plan-");
+ assert!(
+ std::process::Command::new("git")
+ .args(["init", "--quiet"])
+ .arg(&fixture.workspace)
+ .status()
+ .unwrap()
+ .success()
+ );
+ let (provider, mut requests) = Provider::controlled().await;
+ let model = configure(&fixture, &provider.base_url).await;
+ let mut original_grant = Value::Null;
+ let mut original_approval = Value::Null;
+ let mut original_reply = Value::Null;
+ for phase in 0..2 {
+ let host = Host::open(fixture.owner()).await.unwrap();
+ #[cfg(unix)]
+ let endpoint = fixture.workspace.parent().unwrap().join("plan.sock");
+ #[cfg(windows)]
+ let endpoint =
+ std::path::PathBuf::from(format!(r"\\.\pipe\maka-plan-{}", uuid::Uuid::new_v4()));
+ let stop = CancellationToken::new();
+ let cleanup = stop.clone().drop_guard();
+ let server = tokio::spawn(
+ LocalListener::bind(&endpoint)
+ .unwrap()
+ .serve(host.clone(), stop),
+ );
+ let mut peer = Peer::new(host.clone(), "plan-client").await;
+ peer.wait_for_plugins().await;
+ if phase == 0 {
+ let created = peer.rpc("session.create", json!({
+ "sessionId":SESSION,"workspace":{"kind":"host_path","path":fixture.workspace},
+ "sandboxMode":"workspace-write","approvalPolicy":{"kind":"never"},"collaborationMode":"plan",
+ "modelTarget":{"kind":"explicit","connectionId":model.connection_id,"connectionSlug":model.connection_slug,"model":model.model}
+ })).await;
+ assert_eq!(created["ok"], true, "{created}");
+ } else {
+ enable(&mut peer, true).await;
+ }
+ let mut envelope = open(&mut peer).await;
+ if phase == 0 {
+ original_grant = grant(&mut peer, &envelope).await;
+ start(&mut peer, "planning").await;
+ let planning = next(&mut requests).await;
+ let tools = names(&planning.body);
+ assert!(tools.contains("SubmitPlan"), "{tools:?}");
+ for forbidden in [
+ "Shell",
+ "Write",
+ "Edit",
+ "apply_patch",
+ "GoalStatus",
+ "update_plan",
+ "cancel_plan",
+ ] {
+ assert!(!tools.contains(forbidden), "{tools:?}");
+ }
+ // Even a fabricated unadvertised tool call cannot write while planning.
+ planning
+ .reply
+ .send(tool("Shell", json!({"command":"touch should-not-exist"})))
+ .unwrap();
+ let after_rejection = next(&mut requests).await;
+ assert!(!fixture.workspace.join("should-not-exist").exists());
+ after_rejection
+ .reply
+ .send(tool("SubmitPlan", artifact()))
+ .unwrap();
+ let proposed = wait_state(&mut peer, &envelope, |state| {
+ state["proposal"]["status"] == "pending_approval"
+ })
+ .await;
+ assert_eq!(
+ turn_finished(&mut peer, "planning").await["status"],
+ "completed"
+ );
+ assert!(
+ requests.try_recv().is_err(),
+ "SubmitPlan must finish without another model request"
+ );
+ original_approval = approval(&proposed, &original_grant, "approve-original");
+ original_reply = call(&mut peer, &envelope, original_approval.clone()).await;
+ assert_eq!(original_reply["ok"], true, "{original_reply}");
+ assert_eq!(
+ call(&mut peer, &envelope, original_approval.clone()).await,
+ original_reply
+ );
+ let executing = next(&mut requests).await;
+ let tools = names(&executing.body);
+ assert!(
+ tools.contains("update_plan") && tools.contains("cancel_plan"),
+ "{tools:?}"
+ );
+ assert!(!tools.contains("SubmitPlan"), "{tools:?}");
+ assert!(
+ executing.body["messages"]
+ .to_string()
+ .contains("user-approved plan")
+ );
+ executing
+ .reply
+ .send(tool(
+ "update_plan",
+ json!({"steps":[
+ {"id":"change","status":"completed","note":"Behavior verified"},
+ {"id":"verify","status":"completed","note":"Checks passed"}
+ ]}),
+ ))
+ .unwrap();
+ let final_response = next(&mut requests).await;
+ let reported = wait_state(&mut peer, &envelope, |state| {
+ state["execution"]["steps"][1]["status"] == "completed"
+ })
+ .await;
+ assert_eq!(
+ reported["execution"]["phase"]["kind"], "active",
+ "A model report must not outrun canonical Host completion"
+ );
+ let execution_turn = reported["execution"]["phase"]["receipt"]["invocation"]["turn_id"]
+ .as_str()
+ .unwrap()
+ .to_owned();
+ let revoked = peer
+ .rpc(
+ "plugin.authorization",
+ json!({
+ "binding":envelope["binding"],"target":envelope["target"],
+ "command":{"kind":"revoke","id":original_grant}
+ }),
+ )
+ .await;
+ assert_eq!(revoked["ok"], true, "{revoked}");
+ enable(&mut peer, false).await;
+ final_response
+ .reply
+ .send(answer("Verified implementation"))
+ .unwrap();
+ assert_eq!(
+ turn_finished(&mut peer, &execution_turn).await["status"],
+ "completed"
+ );
+ } else {
+ let pending = read(&mut peer, &envelope).await;
+ assert_eq!(pending["execution"]["phase"]["kind"], "active");
+ let renewed = grant(&mut peer, &envelope).await;
+ let reconcile = call(&mut peer, &envelope, json!({
+ "kind":"control","operationId":"renew-settlement","expectedRevision":pending["revision"],
+ "action":{"kind":"reconcile","executionId":pending["execution"]["id"],"grant":renewed}
+ })).await;
+ assert_eq!(reconcile["ok"], true, "{reconcile}");
+ let completed = wait_state(&mut peer, &envelope, |state| {
+ state["execution"]["phase"]["kind"] == "completed"
+ })
+ .await;
+ assert!(
+ requests.try_recv().is_err(),
+ "Recovery must observe the original receipt without rerunning the Plan"
+ );
+ assert_eq!(
+ call(&mut peer, &envelope, original_approval.clone()).await,
+ original_reply
+ );
+ let historic = call(
+ &mut peer,
+ &envelope,
+ json!({"kind":"history","throughRevision":completed["revision"],"after":0}),
+ )
+ .await;
+ assert_eq!(historic["ok"], true, "{historic}");
+ assert_eq!(historic["result"]["value"]["snapshots"][0]["revision"], 1);
+ let stored_artifact = call(
+ &mut peer,
+ &envelope,
+ json!({"kind":"artifact","source":"execution"}),
+ )
+ .await;
+ assert_eq!(stored_artifact["result"]["value"]["artifact"], artifact());
+
+ start(&mut peer, "planning-cancel").await;
+ next(&mut requests)
+ .await
+ .reply
+ .send(tool("SubmitPlan", artifact()))
+ .unwrap();
+ let proposed = wait_state(&mut peer, &envelope, |state| {
+ state["proposal"]["status"] == "pending_approval"
+ })
+ .await;
+ assert_eq!(
+ turn_finished(&mut peer, "planning-cancel").await["status"],
+ "completed"
+ );
+ let revoked = peer
+ .rpc(
+ "plugin.authorization",
+ json!({
+ "binding":envelope["binding"],"target":envelope["target"],
+ "command":{"kind":"revoke","id":original_grant}
+ }),
+ )
+ .await;
+ assert_eq!(revoked["ok"], true, "{revoked}");
+ let rejected = call(
+ &mut peer,
+ &envelope,
+ approval(&proposed, &original_grant, "revoked-approval"),
+ )
+ .await;
+ assert_eq!(rejected["ok"], false, "{rejected}");
+ assert_eq!(
+ read(&mut peer, &envelope).await["revision"],
+ proposed["revision"]
+ );
+ let renewed = grant(&mut peer, &envelope).await;
+ let approved = call(
+ &mut peer,
+ &envelope,
+ approval(&proposed, &renewed, "approve-cancel"),
+ )
+ .await;
+ assert_eq!(approved["ok"], true, "{approved}");
+ next(&mut requests)
+ .await
+ .reply
+ .send(answer("Stopped without completing the steps"))
+ .unwrap();
+ let interrupted = wait_state(&mut peer, &envelope, |state| {
+ state["execution"]["phase"]["kind"] == "interrupted"
+ })
+ .await;
+ let resume = json!({
+ "kind":"control","operationId":"resume-interrupted","expectedRevision":interrupted["revision"],
+ "action":{"kind":"resume","executionId":interrupted["execution"]["id"],"grant":renewed}
+ });
+ let resumed = call(&mut peer, &envelope, resume.clone()).await;
+ assert_eq!(resumed["ok"], true, "{resumed}");
+ assert_eq!(call(&mut peer, &envelope, resume).await, resumed);
+ let executing = next(&mut requests).await;
+ assert!(
+ executing.body["messages"]
+ .to_string()
+ .contains("Current Plan state for this model step")
+ );
+ let active = wait_state(&mut peer, &envelope, |state| {
+ state["execution"]["phase"]["kind"] == "active"
+ })
+ .await;
+ let cancellation = json!({"kind":"control","operationId":"cancel-owned","expectedRevision":active["revision"],
+ "action":{"kind":"cancel","executionId":active["execution"]["id"],"reason":"User abandoned this Plan"}});
+ let cancelled = call(&mut peer, &envelope, cancellation.clone()).await;
+ assert_eq!(cancelled["ok"], true, "{cancelled}");
+ let _ = executing.reply.send(answer("No further work"));
+ wait_state(&mut peer, &envelope, |state| {
+ state["execution"]["phase"]["kind"] == "cancelled"
+ })
+ .await;
+ start(&mut peer, "unrelated").await;
+ let unrelated = next(&mut requests).await;
+ assert_eq!(
+ call(&mut peer, &envelope, cancellation).await,
+ cancelled,
+ "An exact retry must return its original decision"
+ );
+ unrelated
+ .reply
+ .send(answer("Independent planning discussion"))
+ .unwrap();
+ assert_eq!(
+ turn_finished(&mut peer, "unrelated").await["status"],
+ "completed",
+ "Cancelling an old Plan must never stop the Session's newer Turn"
+ );
+ envelope =
+ uncertain_cancellation(&mut peer, &host, &envelope, &renewed, &mut requests).await;
+ }
+ close(&mut peer, &envelope).await;
+ peer.close().await;
+ if phase == 1 {
+ tokio::time::timeout(
+ Duration::from_secs(10),
+ host.wait_until_idle(Duration::ZERO, Duration::from_millis(100)),
+ )
+ .await
+ .expect("Unknown cancelled admission must not keep Host awake");
+ assert!(
+ requests.try_recv().is_err(),
+ "Unknown cancelled admission must never be submitted"
+ );
+ }
+ drop(cleanup);
+ server.await.unwrap().unwrap();
+ }
+}
+
+async fn uncertain_cancellation(
+ peer: &mut Peer,
+ host: &std::sync::Arc<Host>,
+ envelope: &Value,
+ grant: &Value,
+ requests: &mut tokio::sync::mpsc::Receiver<ModelRequest>,
+) -> Value {
+ use maka_assistant::plan::{Command, Phase, Request, repository::Repository};
+ start(peer, "planning-uncertain").await;
+ next(requests)
+ .await
+ .reply
+ .send(tool("SubmitPlan", artifact()))
+ .unwrap();
+ wait_state(peer, envelope, |state| {
+ state["proposal"]["status"] == "pending_approval"
+ })
+ .await;
+ assert_eq!(
+ turn_finished(peer, "planning-uncertain").await["status"],
+ "completed"
+ );
+ enable(peer, false).await;
+ close(peer, envelope).await;
+ // Materialize the crash boundary after durable dispatch but before a known
+ // Host receipt, followed by a persisted cancellation decision.
+ let inspector = maka_plugins::fiber::Fiber::new(
+ "maka.plan",
+ "plan-fixture",
+ maka_plugins::composition::Scope::Profile,
+ )
+ .unwrap();
+ inspector.begin_loading().unwrap();
+ inspector.ready().unwrap();
+ inspector.publish().unwrap();
+ let store = host.plugin_storage(inspector.context()).unwrap();
+ let repo = Repository::new(store.clone(), "maka.plan", SESSION).unwrap();
+ let mut state = repo.current().await.unwrap();
+ let proposal = state.proposal.as_ref().unwrap();
+ state = repo
+ .apply(
+ &Request {
+ operation_id: "uncertain-approval".into(),
+ expected_revision: state.revision,
+ command: Command::Approve {
+ proposal_id: proposal.id.clone(),
+ proposal_revision: proposal.revision,
+ behavior: "maka.plan.execute".to_owned().try_into().unwrap(),
+ grant: serde_json::from_value(grant.clone()).unwrap(),
+ },
+ },
+ 1,
+ )
+ .await
+ .unwrap();
+ let execution = state.execution.as_ref().unwrap().id.clone();
+ state = repo
+ .apply(
+ &Request {
+ operation_id: "uncertain-dispatch".into(),
+ expected_revision: state.revision,
+ command: Command::Dispatch {
+ execution_id: execution.clone(),
+ },
+ },
+ 2,
+ )
+ .await
+ .unwrap();
+ state = repo
+ .apply(
+ &Request {
+ operation_id: "uncertain-cancel".into(),
+ expected_revision: state.revision,
+ command: Command::Cancel {
+ execution_id: execution,
+ reason: "User stopped the pending dispatch".into(),
+ grant: None,
+ },
+ },
+ 3,
+ )
+ .await
+ .unwrap();
+ assert!(matches!(
+ state.execution.unwrap().phase,
+ Phase::AwaitingAdmission
+ ));
+ drop(repo);
+ drop(store);
+ inspector
+ .shutdown(tokio::time::Instant::now() + Duration::from_secs(2))
+ .await
+ .unwrap();
+ enable(peer, true).await;
+ let recovered = open(peer).await;
+ let current = read(peer, &recovered).await;
+ assert_eq!(current["execution"]["phase"]["kind"], "awaiting_admission");
+ assert_eq!(
+ current["execution"]["cancellation"],
+ "User stopped the pending dispatch"
+ );
+ recovered
+}
diff --git a/crates/runtime/src/execution/behavior.rs b/crates/runtime/src/execution/behavior.rs
index 7533478139..12511de14b 100644
--- a/crates/runtime/src/execution/behavior.rs
+++ b/crates/runtime/src/execution/behavior.rs
@@ -28,6 +28,15 @@ impl BehaviorId {
pub fn as_str(&self) -> &str {
&self.0
}
+
+ /// A collaboration-specific registration is required; never silently fall
+ /// back to an unconstrained behavior for another collaboration mode.
+ pub fn for_collaboration(&self, mode: super::CollaborationMode) -> Result<Self, &'static str> {
+ match mode {
+ super::CollaborationMode::Agent => Ok(self.clone()),
+ super::CollaborationMode::Plan => format!("{}:plan", self.0).try_into(),
+ }
+ }
}
impl Default for BehaviorId {
fn default() -> Self {
diff --git a/crates/tool-catalog/src/availability.rs b/crates/tool-catalog/src/availability.rs
index afa1533fe3..3cb4370ca5 100644
--- a/crates/tool-catalog/src/availability.rs
+++ b/crates/tool-catalog/src/availability.rs
@@ -31,6 +31,7 @@ pub const SEARCH: &str = "tool_search";
#[derive(Clone)]
pub struct Availability {
catalog: ToolCatalog,
+ behavior: Option<maka_runtime::execution::BehaviorId>,
active: Arc<Mutex<BTreeSet<String>>>,
}
@@ -91,6 +92,7 @@ impl Availability {
.map_err(|error| ToolError::Failed(error.to_string()))?;
let context = if let Some(captured) = &captured {
let request = crate::plugins::BindingRequest {
+ behavior: self.behavior.clone(),
model,
invocation,
cwd,
@@ -104,6 +106,7 @@ impl Availability {
Ok((
Self {
catalog,
+ behavior: self.behavior.clone(),
active: self.active.clone(),
},
captured,
@@ -114,9 +117,13 @@ impl Availability {
pub fn new(catalog: ToolCatalog) -> Self {
Self {
catalog,
+ behavior: None,
active: Arc::default(),
}
}
+ pub fn set_behavior(&mut self, behavior: maka_runtime::execution::BehaviorId) {
+ self.behavior = Some(behavior);
+ }
pub fn enabled(&self) -> bool {
self.catalog.discovery
&& self
@@ -127,6 +134,7 @@ impl Availability {
pub fn nested(&self) -> Self {
Self {
catalog: self.catalog.nested(),
+ behavior: self.behavior.clone(),
active: self.active.clone(),
}
}
diff --git a/crates/tool-catalog/src/plugins/binding.rs b/crates/tool-catalog/src/plugins/binding.rs
index ec07cb7014..e33d267f60 100644
--- a/crates/tool-catalog/src/plugins/binding.rs
+++ b/crates/tool-catalog/src/plugins/binding.rs
@@ -26,6 +26,8 @@ use tokio_util::sync::CancellationToken;
#[derive(Clone)]
pub struct BindingRequest {
+ /// Frozen behavior identity for visibility decisions, never authority.
+ pub behavior: Option<maka_runtime::execution::BehaviorId>,
pub model: Option<maka_runtime::tools::ModelToolContext>,
pub invocation: Invocation,
pub cwd: String,
diff --git a/crates/tools/src/dispatch.rs b/crates/tools/src/dispatch.rs
index 05bc0103ea..02a40ada06 100644
--- a/crates/tools/src/dispatch.rs
+++ b/crates/tools/src/dispatch.rs
@@ -84,6 +84,11 @@ impl RunTools {
self
}
+ pub fn with_behavior(mut self, behavior: maka_runtime::execution::BehaviorId) -> Self {
+ self.availability.set_behavior(behavior);
+ self
+ }
+
pub fn set_model(&mut self, model: maka_runtime::tools::ModelToolContext) {
self.model = Some(model);
}
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 8b62665bc8..5663bbdf58 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -21,9 +21,10 @@
[简体中文](rust-parity.zh-CN.md)
-Baseline: Rust `c6874775d`, main `f02ac9433` (2026-09-18).
-This records known functional gaps and the whole-domain built-in plugin migration plan, not a
-complete acceptance audit. Target ownership and API extensions below are not claims of implementation.
+Implementation status: 2026-09-25. Upstream features have been selectively
+absorbed through main `9082cf144`; main has not been merged in full.
+This records implemented capabilities, known gaps and explicit limits, not a complete acceptance
+audit. Target ownership and proposed API extensions are not claims of implementation.
Use our own business modules to develop the plugin API: migrate an existing domain and complete
its missing behavior together, then remove its special-purpose Host path. Registering a wrapper
@@ -69,11 +70,13 @@ Client changes are allowed when they remove an obsolete path, as with Graph.
| **Session checklist:** `maka.todo` owns `todo_read`/`todo_write`, typed documents and a live composer Client. | Public namespaced storage provides revision-checked replacement; Remote streams page complete snapshots for the caller's Session. Disable/restart preserves data. No Todo-specific Host service or Desktop IPC remains. |
| **Graph/Swarm:** built-in plugins; behavior selection uses an open typed identity. Graph uses public authorized Session/execution commands, scoped data and read-only preferences, without private Host handles. | Preserve existing orchestration and wakeup behavior. Reuse narrow commands where semantics match; typed domain repositories remain legitimate. |
| **Scheduler:** the plugin owns schedules, frozen triggers, misfire/retry policy and notification withdrawal. Activation receives public scoped storage, authorization, execution and notification capabilities, not scheduling-specific Host services. | Host resolves authorization and admits execution/native delivery. Pausing withdraws unadmitted notifications, including late provider acceptance; accepted executions remain Host-owned. Recovery reuses exact Fire identities and never replays [...]
+| **Jev:** `maka.jev` publishes `maka.jev.evaluate` to native/JSON plugins through admitted scopes, with settings for the full URL, model, timeout, API key and custom headers. | Host owns authorized HTTP, namespaced credentials and settlement. The plugin owns the System One protocol, probability validation and endpoint credential isolation. Returned token metadata is not yet part of Host model accounting. |
| **Web:** `maka.web` owns browserless WebFetch, Tavily search, source selection, credential checks and the settings Client. Native search binds through the public provider-tool contract at each model step. | Host owns authorized HTTP, proxy policy, resource settlement and namespaced credentials. Rust and JS share bindings; provider results/citations remain canonical facts. The old Web RPC, global settings and Tavily vault slot are removed. |
| **Recall:** `maka.recall` owns Unicode literal matching, BM25 ranking, Session diversity and RecallMore passage expansion. | Public Rust/JS history supplies fixed-fence UTF-8 pages and archived metadata. Host owns access checks and the SQLx-managed text projection; no Recall-specific Host service or V8 is required. Incognito withdraws tools; unread sources and clipped passages are explicit. |
-| **Code Mode:** mode selection, nested dispatch and history projection cross several crates. | After the first domain migrations, move its user-facing tool and mode policy where a real contribution boundary helps. Keep V8 ownership, nested-call permissions, dispatch/settlement and canonical history in the runtime. Do not invent a universal executor hook merely to move `exec`. |
+| **Code Mode:** execution is implemented; Code Mode and ApplyPatch have independent per-model settings. Nested dispatch and history projection cross several crates. | Move tool assembly only where it removes concrete coupling. Keep V8 ownership, nested-call permissions, dispatch/settlement and canonical history in the runtime. No universal executor hook is needed to move `exec`. |
| **Files and Shell tools:** registrations are assembled in Host over existing filesystem/process owners. | Tool definitions and assembly can become built-in contributions. Resource ownership, write coordination and PTY cancellation remain Host services. Defer this structural migration until it removes concrete coupling; a plugin per tool adds no value. |
-| **Client Capability / MCP, providers and transport** | Keep their current resource/authority boundaries. Desktop-owned MCP does not move into Host, and model vendors do not each require a plugin. Complete their functional gaps independently of structural migrations. |
+| **Client Capability / MCP:** Session-scoped publication and lifecycle are wired through the public capability contract. | Keep client-owned MCP in the client. Host owns capability selection, authorization and settlement. |
+| **Model providers and adapters:** public Rust/JS registrations support provider authentication/discovery/request policy and protocol adapters. Bundled API providers and ChatGPT subscription consume them; native Responses coexists with AI SDK adapters. | Providers own vendor policy; adapters encode protocols. Host owns connections, credentials, proxy routing, per-step snapshots, admission and accounting. Catalog discovery does not imply inference support. |
Any authorized plugin can request managed ownership with `createRoot({ managed: true, ... })`.
Host commits package/scope ownership and creation identity atomically. Ordinary mutations and other
@@ -86,29 +89,37 @@ Reuse SQLx migrations and domain stores; changing ownership does not require cha
moving all data into KV, or creating a crate per plugin. Runtime contracts must not depend on plugin
implementations; wire adapters may retain existing client vocabulary.
-## Missing functionality and placement
+## Capabilities, gaps and placement
“Plugin + Host” identifies separate responsibilities, not permission to defer either half.
+An explicit unsupported surface is not an automatic implementation commitment. Replaced TS RPCs
+do not require compatibility routes when current consumers use public plugin contracts.
-| Domain | Remaining functionality | Target owner / necessary boundary |
+| Domain | Current capability / remaining work | Target owner / necessary boundary |
| --- | --- | --- |
-| Plan | State and durable receipts are complete: revision/abandonment, version and replan-source checks, frozen submissions, progress/interruption/resume/cancellation, exact retries and fixed-watermark history pages. Tools, Behavior, Remote/Desktop and live execution observation remain unwired. Non-Agent collaboration mode still fails admission. | **Plugin + Host.** The plugin owns workflow and records through public storage. Approving a plan grants no sandbox permissions; Host retains [...]
-| Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
-| Session recap | `maka.session-recap` provides manual generation, durable idempotent receipts, Desktop Session Inspector and native Rust TUI access. The TUI checkpoints original operation identity before generation and explicitly retries that identity after reconnect. Automatic idle generation and the old `session.recap.generate` route remain absent. | **Plugin.** Uses authorized history and the Session model. Recaps are derived data and do not rewrite canonical history or Session metadata. |
+| Plan | `maka.plan` now provides planning/execution behaviors, SubmitPlan/update_plan/cancel_plan, standalone Remote controls, versioned approvals, durable dispatch, Host settlement observation, explicit resume and restart/retirement recovery. Planning enforces a tool ceiling; completion requires successful Host execution as well as reported step completion. UI is deferred. | **Plugin + Host.** Public storage and execution capabilities own the workflow. Approval grants no sandbox permis [...]
+| Goal | `maka.goal` provides Session Inspector, Remote read/arm/start/pause/cancel, durable continuation and exact-operation recovery. `GoalStatus` takes effect after its owned Invocation completes normally. Saved goals require explicit start; automatic adoption of user Turns and an independent evaluator are absent. | **Plugin + Host.** The plugin owns goals and bounded iteration policy; Host owns authorization, receipts and accounting. The token threshold stops future scheduling based [...]
+| Session recap | `maka.session-recap` provides manual generation, durable idempotent receipts, Desktop Session Inspector and native Rust TUI access. The TUI checkpoints original operation identity before generation and explicitly retries that identity after reconnect. Automatic idle generation remains absent. | **Plugin.** Uses authorized bounded text history and the Session model; structured tool-result projection is absent. Recaps are derived data and do not rewrite canonical history [...]
| Daily review | Daily-review query/mutate and scheduled review remain. | **Plugin + Host.** Reuse Scheduler and authorized history/model services. |
-| External agents | `maka.external-agent` provides ACP v1 execution, scoped configuration, Desktop setup/authentication, official Antigravity installation, text/thought/tool streaming and file/permission callbacks. Processes survive between turns; advertised `session/load` restores known identities after restart. Unknown outcomes are fenced. Attachments, opaque conversation forks and terminal callbacks/authentication are explicitly unsupported. | **Plugin + Host.** The shipped adapter co [...]
+| Inbound ACP | `maka acp` uses official SDK 2.2.0 with experimental v2: Session create/list/resume/close, configuration, text/images/resources, canonical replay, streaming, permissions, negotiated forms and exact cancellation. Client MCP servers, additional workspace directories, arbitrary replay cursors and ACP authentication are unsupported. Resume requires an idle Host Session; at most eight Sessions attach per connection. | **Public Client + Host.** The adapter uses public deploymen [...]
+| External agents | `maka.external-agent` uses the official SDK, preferring experimental v2 and negotiating v1. Scoped configuration, setup/authentication, official Antigravity installation, text/thought/tool updates and permission callbacks are implemented; filesystem callbacks use v1. Processes persist across turns; restart uses advertised v1 `session/load` or v2 `session/resume` for the known identity. Attachments, opaque conversation forks and terminal callbacks/authentication remain [...]
| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped mixed model/tool activity paging, and shared native/plugin pricing query/CAS edits are implemented. Consistent summaries include complete bounded breakdowns and missing-data coverage; The Insights plugin provides settings reports, filtering, pagination, persisted views and rate editing; Session Inspector uses the public Session-bound Client slot. | **Plugin + Host.** Reports and rebui [...]
-| Background health | BackgroundTaskHealth process and endpoint checks. | **Plugin + Host.** Health interpretation and Tool in a plugin; Host exposes authorized resource observations and bounded probes. A stored PID is not resource ownership. |
+| Background health | `maka.background-health` provides the `BackgroundTaskHealth` Agent tool, displayed through existing Desktop tool results. Process state and optional HTTP endpoint checks are separate; logs are omitted by default. | **Plugin + Host.** Uses authorized `Read(ref)` and HTTP, with HEAD/GET fallback and no redirects. Network consent is outside the plugin timeout. Restarted orphaned tasks remain explicit; a healthy endpoint does not prove that process owns its port. |
| Session lineage | Public Rust/JS history copying and source reads, native branch/revision create/abandon, independent inherited pruning, Desktop canonical-input editing and complete durable drafts are implemented. Regeneration is removed; edits use revision and resend. | **Host.** One transactional lineage and workspace authority; plugins request changes through commands, not private lineage storage. |
| Session lifecycle | Native and public plugin removal/preview/receipt APIs, atomic family retirement and queued cancellation, restart cleanup and shared-worktree ownership are implemented. Unreferenced conversation bodies, tool payloads and request surfaces are reclaimed in restartable batches while accounting, receipts and referenced history survive. Shared Session queries remain. | **Host.** Preserve referenced history and accepted receipts; unknown process cleanup must not delete its [...]
-| Session transfer | Public Rust/JS historical import supports bounded staging, exact retries, current-ceiling checks and atomic publication. The built-in plugin owns Codex, Claude Code and OpenCode conversion, catalogs, source configuration and durable intents. Its Desktop settings page and standalone Remote share destination reauthorization and receipt recovery. Native bundles preserve original history/proofs, archived output, attachments and accounting through bounded compression and [...]
-| Runtime policy | Shell consumers and ordinary named tool profiles remain; external-agent settings are consumed by the ACP plugin. | **Split.** Shell launch policy and capability ceilings stay in Host. Profiles contribute definitions; Host applies the intersection at admission and per-step capture. Storing settings alone is insufficient. |
+| Session transfer | Public Rust/JS historical import supports bounded staging, exact retries, current-ceiling checks and atomic publication. The built-in plugin owns Codex, Claude Code and OpenCode conversion, catalogs, source configuration and durable intents. Its Desktop settings page and standalone Remote share destination reauthorization and receipt recovery. Native bundles preserve original history/proofs, archived output, attachments and accounting through bounded compression and [...]
+| Runtime policy | Legacy shell policy mutations and ordinary named tool profiles remain unwired; production Shell selection and sandboxing are implemented. External-agent settings are consumed by the ACP plugin. | **Split.** Shell launch policy and capability ceilings stay in Host. Profiles contribute definitions; Host applies the intersection at admission and per-step capture. Storing settings alone is insufficient. |
| Access / collaboration | Credential rotation prepare/revoke; principal revoke; collaboration access, invitation, grant revoke, principal rename/revoke; Turn-request create/query/decide/acknowledge/withdraw. | **Host.** Reuse credential and durable admission authority. Plugins may provide workflows/UI but cannot decide grants, bypass revocation or own canonical accepted Turn requests. |
| Peer Mesh | Create/query/invite/join/leave/remove/close/reconcile, rename/display-name and transit control. | **Host for this rewrite.** Identity, routing and transport recovery must work during plugin recovery/unavailability. Do not add a transport-plugin platform to complete these protocols. |
| Credential export | `configuration.credentials.export`. | **Host.** Explicitly authorized export from the real vault; plugin-scoped credentials are not blanket vault access. |
-| Model providers | Google/Cohere; remaining declared auth, reasoning/usage/options behavior; runtime models.dev refresh; Copilot/xAI inference and credential-backed verification. | **Model/Host layer initially.** Keep shared transport, streaming, retries, metering and request snapshots. Metadata-source policies can later be contributions; no one-plugin-per-vendor mandate. Command Code CLI execution belongs with Executors above. |
+| Model providers | Provider and adapter registration, ChatGPT subscription authentication and native Responses are implemented. Google/Cohere inference remains unavailable despite catalog/discovery support. Remaining declared auth/reasoning/usage/options behavior, runtime models.dev refresh and Copilot/xAI credential-backed verification remain. | **Plugin + Host.** Public provider/adapter contracts are already in use. Extend the relevant provider or adapter while retaining Host credenti [...]
| Diagnostics / hosted runs | `execution.inspect.query`, `host.resources.query`, `hosted.execution.start/cancel`. | **Host**, with optional plugin presentation/orchestration. Inspection reads canonical evidence; hosted execution must preserve environment, ownership and cancellation. It is not merely another Executor name. |
+Antigravity's v1-envelope response to typed v2 initialization permits exactly one fresh native v1
+connection only when that initialize records a `ParseError`. Close the owned probe process first
+and retain the same outer 30-second deadline. Never rewrite responses or retry session/auth/prompt
+work, and never fall back on generic network/authentication failures.
+
## Consumer-driven API work
The kernel exists, but the current SDK is not a universal implementation surface.
@@ -122,11 +133,12 @@ Original TS plugins are not source-compatible with the new SDK.
| WorkHub / Graph / Plan: selectable behavior | Open `BehaviorId` selects a typed Contribution; Graph/Swarm register independently. Host acceptance covers a non-builtin business. Preserve Session defaults and durable per-Turn choices; a requested unavailable behavior fails explicitly. Behavior preparation and input preparation are separate contracts, not one hook bus. |
| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Public Usage reads expose scoped model/tool attempts and refusals without conversation bodies. Fixed-fence summaries and bounded breakdowns are available; Insights consumes them through public Remote and Client APIs; Session I [...]
| Skills / WorkHub / default behavior: business UI and prompt context | Published Clients own the actual Skills picker/management and WorkHub surface through Slots and Remote. Native adapters validate the originating Host and document; connection replacement revokes old Remote leases without replay. Prompt Contributions own business instructions. Inactive features remain visibly unavailable without blocking ordinary chat. |
-| Other TS extension services | New SDK lacks equivalent public registrations/services for LSP routing, commands, Skills/Goals queries, shell environment contributions, Settings definitions, authorization flows and LLM adapter registration; questions/forms and source-input/attachment-copy contracts are public; permission approvals remain Host-owned. Implement domain registries as plugin services where possible, retaining Host authority for sensitive actions. `llm.generate` is not adapter [...]
+| Model providers / adapters | `ctx.modelProviders` and `ctx.modelAdapters` provide public JS registrations alongside typed Rust contracts, including provider authentication and refresh. Protocol streams, resource ownership and canonical settlement share Host semantics. `call.llm.generate` consumes the selected model; it is separate from registration. |
+| Other TS extension services | Public LSP/Commands routes, shell environment Contributions and declarative Settings definitions still need consumer-level assessment. Skills/Goals queries belong to domain Services/Remote; existing public questions/forms, explicit authorization and source-input/attachment copying must be reused. Permission decisions remain Host-owned. |
These are functional extension points to assess and implement, not a promise to copy every TS method.
-TS LLM adapter registration serves plugin model calls; it does not itself register a main Session transport.
-Do not prebuild a generic provider framework, event bus or universal repository for them.
+Current model adapter registrations serve main Session requests and authorized auxiliary calls.
+Use existing provider/adapter contracts; do not add a parallel framework, event bus or universal repository.
Built-in Rust uses typed calls directly, not a JSON/V8 round trip. Rust and JS adapters share
capability semantics, authorization and retirement guarantees; public JS bindings are added for
@@ -144,11 +156,10 @@ it does not reinterpret uncertain work using current policy or defaults.
the same scoped contracts as external plugins. Maintain Rust/JS parity as new consumers appear.
2. **External acceptance:** the JS workflow fixture exercises UI consent, durable background work,
exact receipts, disable/reactivation and revocation across Host restart.
-3. **Missing business domains:** complete Plan/Goal and review;
- finish external adapters and Insights/health. Reuse the domain boundaries rather than first
- implementing new business logic in Host and moving it later.
-4. **Remaining core parity:** complete Session lifecycle/lineage/transfer, policy, access/collaboration,
- Peer Mesh, providers and diagnostics. Pull required core commands into their consumer's earlier
+3. **Missing business workflows:** implement automatic recap and Daily review through public
+ capabilities. Plan backend is complete; its UI remains outside the current scope.
+4. **Remaining core parity:** complete shared Session queries, policy consumers, access/collaboration,
+ Peer Mesh, provider inference gaps and diagnostics/hosted execution. Pull required core commands into their consumer's earlier
stage; core work is not blocked on all plugins or a marketplace. Assess Code Mode/tool assembly
migration after the first domains establish a useful boundary, not as a prerequisite to parity.
@@ -159,7 +170,7 @@ to justify an abstraction. Update the SDK contract in the same slice when it cha
API needs arbitrary Host access, a second authority or many business exceptions, revise the boundary.
Every domain must cover its mutation/query surface, error distinctions, authorization, cancellation,
-lost replies, restart recovery and actual Desktop consumers before being marked complete.
+lost replies, restart recovery and its required real client consumers before being marked complete.
Use a few end-to-end acceptance cases including retirement and reactivation; a registered Tool or
passing schema test is not acceptance.
@@ -187,17 +198,26 @@ shared/dedicated plugin V8s, scoped storage/credentials/files/HTTP/process/PTY/m
Executors and Client Remote; Graph/Swarm and Scheduler are implemented.
Old `agent.graph.*` RPCs were replaced by the plugin route, not left as a second Graph implementation.
-Memory and redaction are excluded; OS sandboxing is deferred. Copilot/xAI live adaptation is deferred
-until suitable credentials. Packaging/deployment is not full product parity: native CLI operators do
-not replace the TS interactive CLI/ACP surface or migrate legacy TS state roots automatically.
-Those product/data migration decisions must not be hidden inside a plugin.
-The TS client/TUI need not be rewritten in Rust; its native-Host compatibility still needs acceptance.
+OS sandboxing is implemented on macOS, Linux and Windows, including approval, full bypass and
+Windows setup. Platform-specific filesystem limits remain documented in `maka-sandbox`.
+Host PTY state uses `alacritty_terminal`, not Node `@xterm/headless`. Native Responses HTTP/WebSocket,
+provider plugins and independent per-model Code Mode/ApplyPatch settings are implemented.
+
+Memory and redaction are excluded. Copilot/xAI credential-backed verification remains deferred.
+Native TUI and inbound/outbound ACP now exist. The TUI team owns its remaining UI acceptance;
+Desktop work is limited to native Host connectivity and required call boundaries pending its redesign.
Legacy state-root migration is a separate scope decision, not implicitly authorized by parity work.
+Latest ACP SDK changes have macOS arm64 focused and CLI smoke verification, but have not been
+rerun on Linux/Windows. The official Antigravity test covered installation/initialization without
+login. Earlier cross-platform evidence does not validate this newer SDK slice.
+
## Evidence
- [Host registry](../crates/runtime-host/src/server/operations.rs), [dispatch](../crates/runtime-host/src/server/dispatch.rs), [operation vocabulary](../crates/protocol/src/operation.rs).
-- [Execution preparation](../crates/runtime-host/src/execution/prepare/environment.rs), [tool assembly](../crates/runtime-host/src/execution/tools.rs), [policy consumers](../crates/runtime-host/src/server/configuration/policy.rs), [provider routing](../crates/runtime-host/src/provider_route.rs).
+- [Execution preparation](../crates/runtime-host/src/execution/prepare/environment.rs), [tool assembly](../crates/runtime-host/src/execution/tools.rs), [policy consumers](../crates/runtime-host/src/server/configuration/policy.rs), [provider binding](../crates/runtime-host/src/execution/provider.rs), [provider routing](../crates/providers/src/api/route.rs).
- [Skills domain](../crates/skills/src/lib.rs), [input preparation](../crates/runtime-host/src/execution/input/prepared.rs), [WorkHub workflow](../crates/workhub/src/control.rs), [Host commands](../crates/runtime-host/src/execution/plugins.rs), [business transactions](../crates/workhub/src/repository.rs), [default prompt](../crates/assistant/src/prompt.rs), [Graph wiring](../crates/runtime-host/src/plugins/graph.rs).
-- [SDK contracts](../packages/plugin-sdk/README.md), [execution services](../crates/plugins/src/execution.rs), [Session behavior](../crates/plugins/src/session.rs), [Scheduler router](../crates/runtime-host/src/server/scheduler.rs).
+- [SDK contracts](../packages/plugin-sdk/README.md), [execution services](../crates/plugins/src/execution.rs), [Session behavior](../crates/plugins/src/session.rs), [Scheduler](../crates/scheduler/src/lib.rs).
+- [ACP](../crates/acp/README.md), [external agents](../crates/external-agent/README.md), [model providers](../crates/providers/README.md), [Responses](../crates/responses/README.md).
+- [Plan backend](../crates/assistant/README.md), [Goal](../crates/goal/README.md), [Session recap](../crates/session-recap/README.md), [Jev](../crates/jev/README.md), [background health](../crates/background-health/README.md), [sandbox](../crates/sandbox/README.md).
- TS [composition](../packages/runtime-host/src/server/execution-composition.ts), [interactive tools](../packages/runtime-host/src/server/interactive-run-composer.ts), [inspection](../packages/runtime-host/src/server/execution-inspect-coordinator.ts), [external imports](architecture/external-session-import-design.md).
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index 1253edf6b8..3f229e73c1 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -21,9 +21,9 @@
[English](rust-parity.md)
-对照基线:Rust `c6874775d`、main `f02ac9433`(2026-09-18)。
-这里记录已知功能缺口和完整领域的内置插件迁移计划,不是完整验收报告。
-下文的目标归属与 API 扩展不代表已经实现。
+实现状态:2026-09-25。上游按功能选择性吸收至 main `9082cf144`,未完整合并 main。
+这里记录已有能力、已知缺口和明确限制,不是完整验收报告。
+下文的目标归属与拟议 API 扩展不代表已经实现。
用我们自己的业务模块开发和验证插件 API:迁移一个已有领域,同时补齐它的缺失行为,
随后删除 Host 专用业务路径。给不变的 Host 业务逻辑套一个插件入口不算迁移完成。
@@ -65,9 +65,10 @@
| **Jev:**`maka.jev` 静态内置插件提供 `maka.jev.evaluate`,原生/JSON 插件通过已准入作用域调用;Desktop 设置页支持完整 URL、模型、超时、API key 和自定义请求头。 | Host 负责 HTTP 授权、凭据命名空间和资源结算;插件负责 System One 协议、概率结果校验和端点凭据隔离。返回的 token 用量是供应商元数据,尚未接入 Host 模型用量账本。 |
| **Web:**`maka.web` 拥有无浏览器 WebFetch、Tavily 搜索、来源选择、凭据验证与设置 Client;原生搜索通过公共 provider-tool 契约在每个模型步骤绑定。 | Host 负责授权 HTTP、代理、资源结算与命名空间凭据;Rust/JS 共用绑定,供应商结果和引用保留为规范事实。旧 Web RPC、全局设置和 Tavily 专用凭据槽已删除。 |
| **Recall:**`maka.recall` 拥有 Unicode 字面词匹配、BM25 排序、Session 多样性及 RecallMore 片段扩展。 | 公共 Rust/JS 历史 API 提供固定水位 UTF-8 分页和归档元数据;Host 拥有访问检查及 SQLx 文本投影,不提供 Recall 专用服务,不需要 V8。隐私模式撤下工具,来源缺失和片段截断明确报告。 |
-| **Code Mode:**模式选择、嵌套派发及历史投影跨越多个 crate。 | 首批领域迁移后,在有实际 Contribution 边界收益时迁移面向用户的工具和模式策略;V8 所有权、嵌套调用权限、派发/结算及规范历史保留 runtime。不为搬迁 `exec` 发明万能执行 hook。 |
+| **Code Mode:**执行已实现,Code Mode 与 ApplyPatch 有独立的模型级设置;嵌套派发及历史投影跨越多个 crate。 | 仅在能消除具体耦合时迁移工具装配;V8 所有权、嵌套调用权限、派发/结算及规范历史保留 runtime。不为搬迁 `exec` 发明万能执行 hook。 |
| **文件与 Shell 工具:**Host 基于现有文件/进程 owner 装配注册。 | 工具定义与装配可以成为内置 Contribution;资源所有权、写入协调和 PTY 取消仍归 Host。结构迁移等能消除具体耦合时再做,不为每个工具建插件。 |
-| **Client Capability/MCP、provider 与传输** | 保持当前资源与权威边界;Desktop MCP 不搬进 Host,模型厂商不强制逐个插件化。功能缺口独立于结构迁移完成。 |
+| **Client Capability/MCP:**Session 作用域发布与生命周期已通过公共能力契约接线。 | 客户端拥有的 MCP 留在客户端;Host 负责能力选择、授权和结算。 |
+| **模型 provider 与 adapter:**公共 Rust/JS 注册支持提供商认证/发现/请求策略及协议适配。内置 API 提供商和 ChatGPT 订阅消费这些契约;原生 Responses 与 AI SDK adapter 并存。 | provider 拥有厂商策略,adapter 负责协议编码;Host 拥有连接、凭据、代理、每步快照、准入和计量。目录发现可用不代表推理协议已支持。 |
任何获准插件都可通过 `createRoot({ managed: true, ... })` 请求受管所有权。
Host 原子提交包/作用域所有者与创建身份;普通修改和其他插件不能绕过该所有权,重放不接管已有的无关 Session。
@@ -77,29 +78,35 @@ Host 标记的输入回执和执行事实是通用 runtime 契约;Skill 解释
沿用 SQLx migrations 和领域存储;归属变化不要求改变磁盘格式、全部搬入 KV 或一个插件一个 crate。
runtime 契约不能反向依赖插件实现,协议适配层可以保留现有客户端词汇。
-## 功能缺口与归属
+## 当前能力、缺口与归属
“插件 + Host”表示明确分工,不表示任何一半可以留待以后。
+明确不支持的表面不自动成为实施承诺;现有消费者使用公共插件契约时,不为已替换的 TS RPC 重建兼容路由。
-| 领域 | 剩余功能 | 目标归属/必要边界 |
+| 领域 | 当前能力/剩余工作 | 目标归属/必要边界 |
| --- | --- | --- |
-| Plan | 状态与持久回执层完成:修订/放弃、版本与重规划来源检查、冻结提交、进度/中断/恢复/取消、精确重试及固定水位历史分页。工具、Behavior、Remote/Desktop 和实际执行观察尚未接入,非 Agent collaboration mode 仍拒绝准入。 | **插件 + Host。** 插件通过公共存储拥有流程与记录;审批计划不授予沙箱权限,Host 保留授权和 Turn 准入。没有 Host 回执只表示待准入,不能标记正在执行。 |
-| Goal | `maka.goal` 已提供 Session Inspector、Remote 查询/保存/启动/暂停/取消、持久续跑及精确操作恢复;`GoalStatus` 报告在对应 Invocation 正常结束后生效。 | **插件 + Host。** 插件拥有目标及有限轮次策略,Host 拥有授权、回执和计量。token 阈值按 Session 新增已观测用量停止后续调度,含其他会话活动,非硬 token/总步数上限。保存后需显式启动;旧 `goal.*` RPC、自动绑定用户 Turn 和独立 evaluator 尚未迁移。 |
-| Session recap | `maka.session-recap` 已提供手动生成、操作 ID 幂等、持久回执及 Desktop Session Inspector/Rust TUI 展示;TUI 在生成前保存原操作身份,重连后显式按原身份重试;使用授权历史与本会话模型,未知结果不自动重试。 | **插件。** 回顾是插件派生数据,不改写规范历史/Session 元数据;输入为有界文本历史,尚无 TS 结构化工具结果投影。自动 idle 触发与旧协议路由未接入。 |
+| Plan | `maka.plan` 已提供规划/执行 behavior、SubmitPlan/update_plan/cancel_plan、独立 Remote 控制、版本审批、持久派发、Host 结算观察、显式恢复及重启/停用恢复。规划受工具上限约束;完成同时要求步骤报告完成与 Host 执行成功。UI 后置。 | **插件 + Host。** 流程消费公共存储与执行能力;审批不授予沙箱权限。未知取消保留原操作,不重投、不维持空闲 Host;更新 consent 后可协调原回执。封口的 Host 交接使用公共 Session 恢复控制。 |
+| Goal | `maka.goal` 已提供 Session Inspector、Remote 查询/保存/启动/暂停/取消、持久续跑及精确操作恢复;`GoalStatus` 报告在对应 Invocation 正常结束后生效。保存后需显式启动;尚不自动绑定用户 Turn,也没有独立完成评估。 | **插件 + Host。** 插件拥有目标及有限轮次策略,Host 拥有授权、回执和计量。token 阈值按 Session 新增已观测用量停止后续调度,含该 Session 的其他活动,非硬 token/总步数上限。 |
+| Session recap | `maka.session-recap` 已提供手动生成、操作 ID 幂等、持久回执及 Desktop Session Inspector/Rust TUI 展示;TUI 在生成前保存原操作身份,重连后显式按原身份重试。自动 idle 触发尚未接入。 | **插件。** 使用授权的有界文本历史与本会话模型,尚无结构化工具结果投影。回顾是插件派生数据,不改写规范历史/Session 元数据;Remote 替代旧协议路由。 |
| Daily review | daily-review 查询/修改与定时复盘待实现。 | **插件 + Host。** 复用 Scheduler、授权历史与模型服务。 |
-| 外部 agent | `maka.external-agent` 已提供 ACP v1 执行、作用域配置、Desktop 设置/认证、官方 Antigravity 安装、文本/思考/工具流及文件/权限回调。跨轮复用进程,重启后通过已声明的 `session/load` 恢复已知身份;结果不确定时拒绝续跑。附件、外部私有对话分叉、终端回调及终端认证明确不支持。 | **插件 + Host。** 具体适配器消费公共 Executor、进程、HTTP、存储和交互能力;Host 管准入与事实。历史复制要求目标 Executor 显式声明 `historyCopy`,不能仅凭已注册 Executor 就复制。 |
+| 入站 ACP | `maka acp` 使用官方 SDK 2.2.0 的实验性 v2,已支持 Session 创建/列表/恢复/关闭、配置、文本/图片/资源、规范历史回放、流式输出、权限、协商后的表单及精确取消。客户端 MCP servers、额外工作区目录、任意回放游标和 ACP 认证明确不支持;恢复要求 Host Session 空闲,每连接最多附加八个 Session。 | **公共 Client + Host。** 适配器仅使用公共部署/Client API;Host 拥有准入和持久事实。取消及有界关闭期间仍持有迟到的原始准入回执,不重投结果未知的工作。 |
+| 外部 agent | `maka.external-agent` 已使用官方 SDK,优先实验性 v2 并协商 v1。作用域配置、设置/认证、官方 Antigravity 安装、文本/思考/工具更新和权限回调已实现,文件回调属于 v1。跨轮复用进程;重启通过已声明的 v1 `session/load` 或 v2 `session/resume` 恢复已知身份。附件、外部私有对话分叉、终端回调及终端认证仍明确不支持。 | **插件 + Host。** 适配器消费公共 Executor、进程、HTTP、存储和交互能力;Host 管准入与事实。结果不确定时拒绝续跑;v2 替换/清空保留消息身份。历史复制要求 Executor 显式声明 `historyCopy`,当前适配器不能克隆外部私有对话。 |
| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型/工具混合活动分页,以及原生/插件共享的报价查询与 CAS 修改。一致快照汇总包含有界完整分组与缺失数据覆盖率;Insights 插件已提供设置报表、筛选、分页、视图持久化和报价编辑;Session Inspector 已使用公共 Session 作用域 Client 插槽。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
| 后台健康 | `maka.background-health` 已提供 `BackgroundTaskHealth` Agent 工具,Desktop 使用现有工具结果展示;进程状态与可选 HTTP 端点探测分开,日志默认省略。 | **插件 + Host。** 复用授权 `Read(ref)` 和 HTTP 服务;HEAD/GET fallback、不跟随重定向,等待网络授权不计入插件超时。重启后的 orphaned 如实返回;端点成功不证明进程拥有监听端口。 |
| Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
| Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用对话正文、工具载荷与请求表面已支持可恢复的分批回收,保留计量、回执及被引用历史;shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
-| Session 迁入迁出 | 公共 Rust/JS 历史导入已支持有界暂存、精确重试、当前权限上限检查和原子发布。内置插件拥有 Codex、Claude Code、OpenCode 的转换、目录、来源配置及持久意图;Desktop 设置页与独立 Remote 共用目标重新授权和回执恢复。原生 bundle 通过有界压缩和原子导入回执保留原始历史/证明、归档输出、附件和计量。Desktop 使用 Host 清单确认,并明确选择目标工作区;不恢复源权限、插件所有权或待执行工作。专用 TUI 导入界面仍待完成。 | **插件 + Host。** adapter 拥有发现与解析;Host 拥有规范材料、身份和发布。导入对话参与历史、分支及压缩,不声称发生了本机执行或用量。原生 bundle 格式仍是 Host 契约。 |
-| Runtime policy | 剩余 shell 消费与普通 named tool profiles;外部 agent 设置已由 ACP 插件消费。 | **拆分。** shell 启动策略和能力上限留在 Host。Profile 提供定义,Host 在准入和每步捕获时取能力交集。只保存设置不算完成。 |
+| Session 迁入迁出 | 公共 Rust/JS 历史导入已支持有界暂存、精确重试、当前权限上限检查和原子发布。内置插件拥有 Codex、Claude Code、OpenCode 的转换、目录、来源配置及持久意图;Desktop 设置页与独立 Remote 共用目标重新授权和回执恢复。原生 bundle 通过有界压缩和原子导入回执保留原始历史/证明、归档输出、附件和计量。Desktop 使用 Host 清单确认,并明确选择目标工作区;不恢复源权限、插件所有权或待执行工作。原生 TUI 已通过公共 terminal app 提供来源管理和导入。 | **插件 + Host。** adapter 拥有发现与解析;Host 拥有规范材料、身份和发布。导入对话参与历史、分支及压缩,不声称发生了本机执行或用量。原生 bundle 格式仍是 Host 契约。 |
+| Runtime policy | 旧 shell policy 修改与普通 named tool profiles 尚未接线;生产 Shell 选择和沙箱已实现。外部 agent 设置已由 ACP 插件消费。 | **拆分。** shell 启动策略和能力上限留在 Host。Profile 提供定义,Host 在准入和每步捕获时取能力交集。只保存设置不算完成。 |
| 接入/协作 | credential rotation prepare/revoke、principal revoke;collaboration access、邀请、grant revoke、principal rename/revoke;Turn-request create/query/decide/acknowledge/withdraw。 | **Host。** 复用凭据和持久化准入权威。插件可以提供流程/UI,但不能决定授权、绕过撤销或拥有规范的已接受 Turn request。 |
| Peer Mesh | create/query/invite/join/leave/remove/close/reconcile,rename/display-name、transit 控制。 | **本次重写保留 Host 实现。** 身份、路由与传输恢复必须在插件不可用时工作,不为补这些协议再造传输插件平台。 |
| 凭据导出 | `configuration.credentials.export`。 | **Host。** 从实际 vault 进行明确授权的导出;插件自己的凭据空间不授予整个 vault 的访问权。 |
-| 模型 provider | Google/Cohere、其余声明的鉴权及 reasoning/usage/选项行为、运行中 models.dev 刷新;Copilot/xAI 推理及凭证实测。 | **先保留 Model/Host 层。** 共用传输、流式处理、重试、计量和请求快照;元数据来源策略以后可成为 Contribution,不强求一家厂商一个插件。Command Code CLI 归上面的 Executor。 |
+| 模型 provider | provider/adapter 注册、ChatGPT 订阅认证和原生 Responses 已实现。Google/Cohere 虽有目录/发现支持,推理仍不可用;其余声明的鉴权/reasoning/usage/选项行为、运行中 models.dev 刷新及 Copilot/xAI 凭证实测仍待补齐。 | **插件 + Host。** 公共 provider/adapter 契约已有实际消费者;扩展相应提供商或适配器,保留 Host 的凭据权威、传输、快照和计量。 |
| 诊断/托管执行 | `execution.inspect.query`、`host.resources.query`、`hosted.execution.start/cancel`。 | **Host**,呈现/编排可以由插件提供。检查读取规范证据;hosted execution 必须保证环境、所有权及取消,不能简单视为另一个 Executor 名称。 |
+Antigravity 在 typed v2 initialize 中返回 v1 envelope 时,仅该初始化明确记录的 `ParseError`
+允许一次新的原生 v1 连接;先关闭自有探测进程,并共用外层 30 秒期限。不改写响应,不重试
+session/auth/prompt 业务,也不因一般网络或认证错误回退。
+
## 由真实消费者驱动的 API 工作
内核已有,不等于当前 SDK 能直接实现全部业务。旧 TS 插件不与新 SDK 源码兼容。
@@ -112,11 +119,12 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
| WorkHub/Graph/Plan:可选择的 behavior | 已用开放的 `BehaviorId` 选择类型化 Contribution,Graph/Swarm 独立注册;非内置业务已通过 Host 验收。保留 Session 默认值和持久单 Turn 选择;请求的 behavior 不可用时明确失败。behavior 准备与输入准备是独立契约,不合并为 hook 总线。 |
| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。公共 Usage 读取提供作用域内的模型/工具物理调用和派发前拒绝,不暴露对话正文;固定快照汇总与有界分组已提供;Insights 已通过公共 Remote/Client API 消费这些能力;Session Inspector 已使用公共 Session 作用域 Client 插槽。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
| Skills/WorkHub/默认行为:业务 UI 与 Prompt 上下文 | 发布的 Client 通过 Slot 和 Remote 拥有真实 Skills 选择器/管理页及 WorkHub 界面。原生适配验证原 Host 与 document;连接换代撤销旧 Remote 租约,不重放调用。Prompt Contribution 拥有业务指令。功能停用明确显示不可用,不阻塞普通聊天。 |
-| 其余 TS 扩展服务 | 新 SDK 尚缺等价的公开 LSP 路由、Commands、Skills/Goals 查询、shell 环境变量 Contribution、Settings 定义、授权流程及 LLM adapter 注册;问题/表单、源输入与附件复制已有公共契约;权限审批仍归 Host。能由插件服务实现的领域注册放在插件侧,敏感行为权威仍归 Host。`llm.generate` 不等于 adapter 注册。 |
+| 模型 provider/adapter | `ctx.modelProviders`、`ctx.modelAdapters` 已提供公开 JS 注册及对应 Rust 类型化契约,包含提供商认证与刷新;协议流、资源归属及规范结算共用 Host 语义。`call.llm.generate` 消费选定模型,与注册能力分开。 |
+| 其余 TS 扩展服务 | 公开 LSP/Commands 路由、shell 环境变量 Contribution 和声明式 Settings 定义仍需按实际消费者评估。Skills/Goals 查询应使用领域 Service/Remote;复用已有公共问题/表单、显式授权、源输入和附件复制契约。权限决定仍归 Host。 |
这些是需要补齐或适配的功能接口,不是逐个复制 TS 方法。
-TS 的 LLM adapter 注册服务于插件模型调用,本身不等于主 Session 的模型传输注册。
-不要先造通用 provider 框架、事件总线或万能 repository。
+当前模型 adapter 注册同时服务主 Session 请求及授权的辅助调用。
+复用已有 provider/adapter 契约,不另造平行框架、事件总线或万能 repository。
内置 Rust 直接使用类型化调用,不绕行 JSON/V8。Rust 与 JS 适配共用能力语义、授权及退休保证;
新增跨语言能力契约有实际消费者时同步补公开 JS 绑定,不要求 Rust 领域 repository 一并暴露为 JS API。
@@ -129,14 +137,14 @@ TS 的 LLM adapter 注册服务于插件模型调用,本身不等于主 Sessio
1. **公共 API 消费者已迁移:**Skills、默认助手、Scheduler、Graph、WorkHub 与外部插件使用同等受限契约;新增消费者时维持 Rust/JS 对等。
2. **外部验收:**JS workflow fixture 覆盖 UI 授权、持久后台工作、精确回执、停用/恢复以及跨 Host 重启的授权撤销。
-3. **缺失业务领域:**完成 Plan 和自动复盘;完成外部 adapter,补齐 Goal 的旧客户端协议与自动绑定策略。复用领域边界,不先在 Host 写新业务再搬一次。
-4. **其余核心等价:**完成 Session 生命周期/谱系/迁入迁出、policy、接入/协作、Peer Mesh、provider 和诊断。前面消费者所需的核心命令前置到对应阶段,核心工作不等待全部插件或商店。首批领域验证边界后评估 Code Mode/工具装配迁移,不将其作为功能等价的前提。
+3. **缺失业务链路:**通过公共能力实现自动 recap 与 Daily review。Plan 后端已完成,UI 不在当前范围内。
+4. **其余核心等价:**完成 shared Session 查询、policy 消费、接入/协作、Peer Mesh、provider 推理缺口及诊断/托管执行。前面消费者所需的核心命令前置到对应阶段,核心工作不等待全部插件或商店。首批领域验证边界后评估 Code Mode/工具装配迁移,不将其作为功能等价的前提。
每个领域按“真实消费者及不变量 → 最小类型化 API 与消费者一起实现 → 验证生命周期和失败行为 → 删除旧 Host 业务路径”推进。
确实共享契约时用第二个已有消费者验证,不虚构业务来证明抽象。契约变化在同一切片更新 SDK。
若 API 需要任意 Host 访问、第二份权威或大量业务例外,应重划边界。
-一个领域必须覆盖操作、错误区分、授权、取消、丢回复、重启恢复及实际 Desktop 消费,才能标为完成。
+一个领域必须覆盖操作、错误区分、授权、取消、丢回复、重启恢复及所需的真实客户端消费,才能标为完成。
保留少量包含退休/重新启用的端到端验收;注册了工具或 schema 测试通过都不等于完成。
首批迁移还必须证明:
@@ -155,16 +163,25 @@ WorkHub、普通 resume、文件/shell/PTY、含 Desktop MCP 的 Client Capa
文件/HTTP/进程/PTY/模型/客户端调用、Executor、Client Remote,以及 Graph/Swarm 和 Scheduler 已接线。
旧 `agent.graph.*` RPC 已被插件路由替代,不再重建第二套 Graph。
-Memory、脱敏排除;OS 沙箱后置。Copilot/xAI 凭证实测按既有约定后置。
-部署交付不等于完整产品兼容:原生 CLI 运维命令不替代 TS 交互式 CLI/ACP 产品面,
-也不会自动迁移 TS state root。这些产品/数据迁移决策不能藏在插件内。
-不要求把 TS 客户端/TUI 一并改成 Rust,但需要验收其与原生 Host 的兼容性。
+OS 沙箱已覆盖 macOS、Linux 和 Windows,包括审批、完全绕过和 Windows 安装准备;
+平台文件系统限制见 `maka-sandbox`。Host PTY 状态已使用 `alacritty_terminal`,不再依赖
+Node `@xterm/headless`。原生 Responses HTTP/WebSocket、provider 插件及独立的模型级
+Code Mode/ApplyPatch 设置已实现。
+
+Memory、脱敏排除;Copilot/xAI 凭证实测按既有约定后置。
+原生 TUI 和入站/出站 ACP 已存在;TUI 剩余界面验收由 TUI 团队负责。
+Desktop 等待团队重做,当前仅维护原生 Host 接入和必要调用边界。
旧 state root 的迁移是单独的范围决策,不能从功能等价任务中自动推导授权。
+最新 ACP SDK 变更已有 macOS arm64 聚焦测试和 CLI smoke 验证,尚未在 Linux/Windows 复验。
+官方 Antigravity 测试只覆盖安装/初始化,未登录;此前跨平台证据不能代替本次 SDK 变更的验收。
+
## 依据
- [Host 注册](../crates/runtime-host/src/server/operations.rs)、[dispatch](../crates/runtime-host/src/server/dispatch.rs)、[协议词汇](../crates/protocol/src/operation.rs)。
-- [执行准备](../crates/runtime-host/src/execution/prepare/environment.rs)、[工具装配](../crates/runtime-host/src/execution/tools.rs)、[设置消费](../crates/runtime-host/src/server/configuration/policy.rs)、[provider 路由](../crates/runtime-host/src/provider_route.rs)。
+- [执行准备](../crates/runtime-host/src/execution/prepare/environment.rs)、[工具装配](../crates/runtime-host/src/execution/tools.rs)、[设置消费](../crates/runtime-host/src/server/configuration/policy.rs)、[provider 绑定](../crates/runtime-host/src/execution/provider.rs)、[provider 路由](../crates/providers/src/api/route.rs)。
- [Skills 领域](../crates/skills/src/lib.rs)、[输入准备](../crates/runtime-host/src/execution/input/prepared.rs)、[WorkHub 流程](../crates/workhub/src/control.rs)、[Host 命令](../crates/runtime-host/src/execution/plugins.rs)、[业务事务](../crates/workhub/src/repository.rs)、[默认 Prompt](../crates/assistant/src/prompt.rs)、[Graph 接线](../crates/runtime-host/src/plugins/graph.rs)。
-- [SDK](../packages/plugin-sdk/README.zh-CN.md)、[执行服务](../crates/plugins/src/execution.rs)、[Session behavior](../crates/plugins/src/session.rs)、[Scheduler 路由](../crates/runtime-host/src/server/scheduler.rs)。
+- [SDK](../packages/plugin-sdk/README.zh-CN.md)、[执行服务](../crates/plugins/src/execution.rs)、[Session behavior](../crates/plugins/src/session.rs)、[Scheduler](../crates/scheduler/src/lib.rs)。
+- [ACP](../crates/acp/README.zh-CN.md)、[外部 agent](../crates/external-agent/README.zh-CN.md)、[模型提供商](../crates/providers/README.zh-CN.md)、[Responses](../crates/responses/README.zh-CN.md)。
+- [Plan 后端](../crates/assistant/README.zh-CN.md)、[Goal](../crates/goal/README.zh-CN.md)、[Session recap](../crates/session-recap/README.zh-CN.md)、[Jev](../crates/jev/README.zh-CN.md)、[后台健康](../crates/background-health/README.zh-CN.md)、[沙箱](../crates/sandbox/README.zh-CN.md)。
- TS [composition](../packages/runtime-host/src/server/execution-composition.ts)、[交互工具](../packages/runtime-host/src/server/interactive-run-composer.ts)、[执行检查](../packages/runtime-host/src/server/execution-inspect-coordinator.ts)、[外部导入](architecture/external-session-import-design.zh-CN.md)。
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index a2edee461d..5891cb009e 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -184,6 +184,16 @@ Summary `durationMs` totals observed model/tool execution time, excluding unknow
`ctx.pricing.query({ kind: 'start' })` reads the public rate catalog during activation. Continue with the returned revision and `nextOffset`; `revision_changed` requires a new scan. `call.pricing.update({ expectedRevision, mutation })` requires explicit profile `manage_pricing` consent. Edits share the native CAS and configuration notices, affect future admissions only, and finish after admission even if the caller stops waiting. A lost reply can yield `revision_conflict` on retry; query [...]
+## Behavior selection
+
+Tool capture receives a frozen `behavior` identity, or `null` when unavailable. It is
+observation for tool visibility, not execution authority. Native and JS bindings share this field.
+
+Ordinary Session selection qualifies behavior registrations by collaboration mode: Agent uses
+the configured name, Plan uses `<name>:plan` (for example `default:plan`). Missing registrations
+fail admission. An authorized explicit per-Turn behavior selects its registered name directly;
+the actual selected identity is frozen with execution and restored on continuation.
+
## Model adapters
`ctx.modelAdapters.register(name, open)` publishes a protocol adapter. `open('request' | 'conversation')` returns `stream(request, context)` and optional `confirm(history)`. Rust uses `maka_plugins::model::ProviderAdapter` and the same typed events, HTTP and WebSocket contracts. Model overrides select an adapter by `adapter`; defaults are `responses`, `chat-completions` and `anthropic-messages`.
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index 0c452309b9..4493a5929f 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -188,6 +188,15 @@ Host 插件通过 `ctx.tui.app(name, { read, submit, recover? }, descriptor)`
`ctx.pricing.query({ kind: 'start' })` 在激活期间即可读取公共报价。使用返回的 revision 与 `nextOffset` 继续分页,遇到 `revision_changed` 则重新读取。`call.pricing.update({ expectedRevision, mutation })` 需要 profile 范围的 `manage_pricing` 明确授权。修改复用原生 CAS 与配置通知,只影响后续准入;已接受的修改不会因调用者停止等待而中断。丢回复后重试可能返回 `revision_conflict`,应先查询再决定下一次修改。
+## Behavior 选择
+
+工具 capture 获得冻结的 `behavior` 身份,不可用时为 `null`;它仅用于工具可见性判断,
+不是执行授权。原生与 JS 绑定共用该字段。
+
+普通 Session 按 collaboration mode 选择 behavior 注册:Agent 使用配置名称,Plan 使用
+`<name>:plan`(例如 `default:plan`),缺少注册时拒绝准入。经过授权的显式单 Turn behavior
+直接选择对应注册;实际选定身份随执行冻结,并在 continuation 中恢复。
+
## 模型适配器
`ctx.modelAdapters.register(name, open)` 注册协议适配器。`open('request' | 'conversation')` 返回 `stream(request, context)` 和可选的 `confirm(history)`。Rust 使用 `maka_plugins::model::ProviderAdapter`,共享类型化事件、HTTP 与 WebSocket 契约。模型 override 的 `adapter` 指定贡献名称;默认名称为 `responses`、`chat-completions`、`anthropic-messages`。
diff --git a/packages/plugin-sdk/src/host.ts b/packages/plugin-sdk/src/host.ts
index 714436c75a..300260c79f 100644
--- a/packages/plugin-sdk/src/host.ts
+++ b/packages/plugin-sdk/src/host.ts
@@ -358,6 +358,8 @@ export interface HostContext {
capture: (
request: {
readonly invocation: Invocation;
+ /** Frozen behavior identity for visibility, never execution authority. */
+ readonly behavior: string | null;
readonly cwd: string;
readonly tools: readonly string[];
readonly model: ModelToolContext | null;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org