diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index 57c2c40da4..e58726c83d 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -31575,6 +31575,25 @@ export const contracts: { additionalProperties: false, required: ['kind', 'taskId', 'revision', 'input'], }, + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'approve', + }, + taskId: { + type: 'string', + }, + revision: { + type: 'integer', + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'taskId', 'revision'], + }, { type: 'object', properties: { @@ -31898,6 +31917,35 @@ export const contracts: { additionalProperties: false, required: ['kind', 'target', 'model'], }, + { + description: + 'Read-only investigation in a fresh worker. The target is the current Bot.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'discovery', + }, + target: { + $ref: '#/$defs/AgentScheduledTarget', + }, + model: { + $ref: '#/$defs/ModelSelection', + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ThinkingLevel', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'target', 'model'], + }, ], }, AutomationDelivery: { @@ -32166,6 +32214,28 @@ export const contracts: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'AgentScheduleRead', oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'discovery', + }, + sessionId: { + type: 'string', + }, + cursor: { + type: ['string', 'null'], + }, + expectedRevision: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'sessionId'], + }, { type: 'object', properties: { @@ -32289,6 +32359,9 @@ export const contracts: { script: { type: 'boolean', }, + approved: { + type: 'boolean', + }, status: { $ref: '#/$defs/Status', }, @@ -32304,7 +32377,7 @@ export const contracts: { }, }, additionalProperties: false, - required: ['id', 'revision', 'title', 'sessionId', 'script', 'status'], + required: ['id', 'revision', 'title', 'sessionId', 'script', 'approved', 'status'], }, Status: { type: 'string', @@ -32327,12 +32400,15 @@ export const contracts: { misfire: { $ref: '#/$defs/Misfire', }, + approved: { + type: 'boolean', + }, pendingFire: { type: ['string', 'null'], }, }, additionalProperties: false, - required: ['revision', 'task', 'timezone', 'misfire'], + required: ['revision', 'task', 'timezone', 'misfire', 'approved'], }, Task: { type: 'object', @@ -32611,6 +32687,35 @@ export const contracts: { additionalProperties: false, required: ['kind', 'target', 'model'], }, + { + description: + 'Read-only investigation in a fresh worker. The target is the current Bot.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'discovery', + }, + target: { + $ref: '#/$defs/AgentScheduledTarget', + }, + model: { + $ref: '#/$defs/ModelSelection', + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ThinkingLevel', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'target', 'model'], + }, ], }, AutomationDelivery: { @@ -40290,6 +40395,28 @@ export const contracts: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'AgentScheduleRead', oneOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + const: 'discovery', + }, + sessionId: { + type: 'string', + }, + cursor: { + type: ['string', 'null'], + }, + expectedRevision: { + type: ['integer', 'null'], + format: 'uint64', + minimum: 0, + }, + }, + additionalProperties: false, + required: ['kind', 'sessionId'], + }, { type: 'object', properties: { @@ -40413,6 +40540,9 @@ export const contracts: { script: { type: 'boolean', }, + approved: { + type: 'boolean', + }, status: { $ref: '#/$defs/Status', }, @@ -40428,7 +40558,7 @@ export const contracts: { }, }, additionalProperties: false, - required: ['id', 'revision', 'title', 'sessionId', 'script', 'status'], + required: ['id', 'revision', 'title', 'sessionId', 'script', 'approved', 'status'], }, Status: { type: 'string', @@ -40451,12 +40581,15 @@ export const contracts: { misfire: { $ref: '#/$defs/Misfire', }, + approved: { + type: 'boolean', + }, pendingFire: { type: ['string', 'null'], }, }, additionalProperties: false, - required: ['revision', 'task', 'timezone', 'misfire'], + required: ['revision', 'task', 'timezone', 'misfire', 'approved'], }, Task: { type: 'object', @@ -40735,6 +40868,35 @@ export const contracts: { additionalProperties: false, required: ['kind', 'target', 'model'], }, + { + description: + 'Read-only investigation in a fresh worker. The target is the current Bot.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'discovery', + }, + target: { + $ref: '#/$defs/AgentScheduledTarget', + }, + model: { + $ref: '#/$defs/ModelSelection', + }, + thinkingLevel: { + anyOf: [ + { + $ref: '#/$defs/ThinkingLevel', + }, + { + type: 'null', + }, + ], + }, + }, + additionalProperties: false, + required: ['kind', 'target', 'model'], + }, ], }, AutomationDelivery: { diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts index 92576a0c65..6f92d6a3a2 100644 --- a/packages/plugin-sdk/src/generated/runtime.ts +++ b/packages/plugin-sdk/src/generated/runtime.ts @@ -2430,6 +2430,12 @@ export type AgentRecapFailure = | 'incomplete_output' | 'model_unavailable'; export type AgentScheduleRead = + | { + kind: 'discovery'; + sessionId: string; + cursor?: string | null; + expectedRevision?: number | null; + } | { kind: 'list'; cursor?: string | null; @@ -2504,6 +2510,12 @@ export type AgentScheduledEffect = target: AgentScheduledTarget; model: ModelSelection; thinkingLevel?: ThinkingLevel | null; + } + | { + kind: 'discovery'; + target: AgentScheduledTarget; + model: ModelSelection; + thinkingLevel?: ThinkingLevel | null; }; export type AutomationDelivery = 'steering' | 'followup'; export type Creator = @@ -2530,6 +2542,11 @@ export type AgentScheduleChange = revision: number; input: AgentScheduledInput; } + | { + kind: 'approve'; + taskId: string; + revision: number; + } | { kind: 'pause'; taskId: string; @@ -7119,6 +7136,7 @@ export interface AgentScheduledSummary { title: string; sessionId: string; script: boolean; + approved: boolean; status: Status; nextFireAt?: number | null; pendingFire?: string | null; @@ -7129,6 +7147,7 @@ export interface AgentScheduledDetail { task: Task; timezone: string; misfire: Misfire; + approved: boolean; pendingFire?: string | null; } export interface Task { diff --git a/plugins/agent/api/src/contract/scheduler.rs b/plugins/agent/api/src/contract/scheduler.rs index 74b4090dda..a96ad9aa64 100644 --- a/plugins/agent/api/src/contract/scheduler.rs +++ b/plugins/agent/api/src/contract/scheduler.rs @@ -45,13 +45,20 @@ pub enum ScheduledEffect { model: ModelSelection, thinking_level: Option, }, + /// Read-only investigation in a fresh worker. The target is the current Bot. + Discovery { + target: ScheduledTarget, + model: ModelSelection, + thinking_level: Option, + }, } impl ScheduledEffect { pub fn target(&self) -> &ScheduledTarget { match self { Self::Session { target, .. } | Self::ExecutorSession { target, .. } - | Self::AgentRun { target, .. } => target, + | Self::AgentRun { target, .. } + | Self::Discovery { target, .. } => target, } } } @@ -113,6 +120,11 @@ pub struct ScheduledInput { )] #[schemars(rename = "AgentScheduleRead")] pub enum ScheduleRead { + Discovery { + session_id: String, + cursor: Option, + expected_revision: Option, + }, List { cursor: Option, expected_revision: Option, @@ -130,6 +142,7 @@ pub struct ScheduledSummary { pub title: String, pub session_id: String, pub script: bool, + pub approved: bool, pub status: maka_scheduler::task::Status, pub next_fire_at: Option, pub pending_fire: Option, @@ -143,6 +156,7 @@ pub struct ScheduledDetail { pub task: maka_scheduler::task::Task, pub timezone: String, pub misfire: TaskMisfire, + pub approved: bool, pub pending_fire: Option, } #[derive(Serialize, JsonSchema)] @@ -183,6 +197,10 @@ pub enum ScheduleChange { revision: u64, input: ScheduledInput, }, + Approve { + task_id: String, + revision: u64, + }, Pause { task_id: String, revision: u64, diff --git a/plugins/agent/native/README.md b/plugins/agent/native/README.md index 5de46a93fb..1206453a56 100644 --- a/plugins/agent/native/README.md +++ b/plugins/agent/native/README.md @@ -118,6 +118,44 @@ The default composition enables `recaps`. The Session recap command reads cached The default composition enables `goals`. `/goal` or the composer’s + menu opens its panel; `/goal ` starts immediately. Goals continue through the existing Session queue without an iteration limit. `CreateGoal`, `GetGoal` and `GoalStatus` work in Direct and Code Mode; the current objective, progress and budget accompany model requests, including after compaction. A model-created Goal owns its creating turn. Pause stops current work; Resume continues with the selected model and settings; Cancel targets only that Goal. Transient execution failures remain recoverable. Token budgets are optional scheduling thresholds checked between turns; missing usage is reported as incomplete rather than stopping work, and users can adjust budgets. Reads expose current usage, remaining budget and active elapsed time; completed usage stays frozen after cleanup. Goal iterations retain their original admitted Run and Host receipt identities across restart. Recovery reconciles that Run’s effects and physical cleanup; a later iteration is a new policy-authorized submission, not a physical handoff or request replay. Archived Sessions and changed execution prerequisites require user action before further execution. +## Read-only discovery + +With `bot` and `scheduledTasks` enabled, the current Bot can propose a `ScheduledTask` +with `effect: "discovery"`. As with other model-created schedules, this is a paused +proposal until a human approves its exact workspace, permissions and model through +`schedule`. Human-created schedules use the `discovery` effect with that Bot as the +target. Existing `once`, `interval` and `cron` timing, `trigger_now`, `pause` and +`resume` controls apply; no discovery work is enabled by default. + +Each fire creates a fresh ordinary worker Session. It inherits the approved workspace +and model but uses read-only permissions without permission requests. Its tools are +the composition's Files read/image/glob/grep operations, Recall when enabled, and +`DiscoveryComplete`; the coordinator's tools, MCP and shell access are not inherited. +The worker must call `DiscoveryComplete` with either +`{"kind":"no_report","note":"What was checked"}` or +`{"kind":"report","text":"Findings and source references"}`. This records the +result and ends the Turn. Ordinary text without that call is an incomplete investigation. + +One investigation, including an outstanding report handoff, occupies the shared slot. +The scheduler waits for actual execution cleanup: admission is not completion. +`no_report` settles silently; a report is admitted to the Bot once, using its existing +notification path, where the Bot decides whether to call `SendUser`. The recorded run +links to the worker and distinguishes report admission from user delivery. Pausing +cancels the original worker Run and retracts pending reports. Consumed Bot messages +remain history. Restart rechecks the original source approval and pending fire before +queue consumers start; it reconciles the original Run and receipt instead of creating +replacement work. An interrupted terminal run is recorded as failed. + +Disabling Bot or scheduling withdraws outstanding investigation work and unsent reports. +Resuming a plan schedules future work without reviving its cancelled fire. Settlement +tracks the investigation's original Turn, so later user work in its Session cannot hold +the investigation slot. Result-write uncertainty remains an unknown tool outcome. + +Plans, the active job and completed receipts use the existing scheduler KV namespace; +no separate database or lease is introduced. This slice does not import TS discovery +data, add shared notebook memory or observe external events. The [discovery tests](src/owner/driver/tests/model_recovery/discovery.rs) +exercise the real Host with local model fixtures. + [Source](src/lib.rs) · [Cargo manifest](Cargo.toml) diff --git a/plugins/agent/native/README.zh-CN.md b/plugins/agent/native/README.zh-CN.md index 06e5fa2b7c..d2a84b9325 100644 --- a/plugins/agent/native/README.zh-CN.md +++ b/plugins/agent/native/README.zh-CN.md @@ -114,6 +114,36 @@ Maka 默认组合启用 `automaticTitles`。通过 `automaticTitle: true` 创建 默认组合启用 `goals`。`/goal` 或输入框加号菜单打开目标面板;`/goal 目标内容` 直接开始。目标复用现有会话队列自动续跑,不设迭代上限。`CreateGoal`、`GetGoal` 和 `GoalStatus` 可从 Direct 和 Code Mode 调用;每次模型请求都携带当前目标、进度和预算,压缩后仍可读取。模型创建的目标将当前轮作为第一轮。暂停停止当前工作,继续使用所选模型与设置,取消只作用于此目标。临时执行失败允许恢复。Token 预算为可选的跨轮调度阈值;缺失计量如实显示为用量不完整,不会因此停工,用户可以调整预算。读取提供实时用量、剩余预算和累计执行时间;完成并清理后,用量不再随普通聊天增长。重启保留目标迭代原有的已接纳 Run 与 Host 回执身份;恢复对账该 Run 的效果及物理清理,后续迭代仍须由领域策略授权新提交,不是物理执行交接或原请求重放。归档会话或执行前提变更时,需要用户处理后继续。 +## 只读主动发现 + +启用 `bot` 和 `scheduledTasks` 后,当前 Bot 可通过 `ScheduledTask` 提议 +`effect: "discovery"`。与其他模型创建的定时任务一样,提议保持暂停,直到用户通过 +`schedule` 批准其精确工作区、权限和模型。用户直接创建时使用 `discovery` 效果, +以当前 Bot 为目标。沿用 `once`、`interval`、`cron` 定时及 `trigger_now`、`pause`、 +`resume` 控制;默认不会启用任何调查。 + +每次触发新建一个普通 worker Session,沿用已批准的工作区和模型,权限收窄为只读, +禁止申请额外权限。工具仅包括当前组合中的 Files 读取、图片读取、glob、grep,已启用的 +Recall 和 `DiscoveryComplete`;不继承协调工具、MCP 或 shell 能力。worker 必须调用 +`DiscoveryComplete`,提交 `{"kind":"no_report","note":"检查了什么"}` 或 +`{"kind":"report","text":"发现及来源"}`。调用持久记录结果并结束本轮;只输出普通文本 +属于未完整提交调查结果。 + +所有调查共用一个执行位,等待报告交接时也占位。调度器等待真实执行清理结束,不把接纳 +当作完成。`no_report` 静默结算;报告通过已有通知路径只向 Bot 接纳一次,由 Bot 判断 +是否调用 `SendUser`。运行记录链接到 worker,并区分“报告已接纳”和“用户已收到”。 +暂停会取消原 worker Run,并撤回尚未消费的报告;已消费的 Bot 消息保留为历史。 +重启时在队列消费者启动前重新检查原来源授权与待处理触发,按原 Run 和回执恢复, +不新建替代调查。被中断并终结的执行记录为失败。 + +停用 Bot 或调度会撤销尚未结束的调查与未发送报告。恢复计划只安排后续工作,不恢复 +已取消的那次触发。结算按原调查 Turn 跟踪,因此用户之后在同一 Session 中开展的工作 +不会继续占据调查位。结果写入不确定时保留工具结果未知状态。 + +计划、当前调查及已完成回执复用调度器 KV 命名空间,不增加独立数据库或租约。 +本阶段不导入 TS 主动发现数据、不增加共享记事本或外部事件监听。 +[调查测试](src/owner/driver/tests/model_recovery/discovery.rs) 使用真实 Host 和本地模型夹具验证。 + [源码](src/lib.rs) · [Cargo 配置](Cargo.toml) diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs index 2c2784d14d..d26f273f8e 100644 --- a/plugins/agent/native/src/owner.rs +++ b/plugins/agent/native/src/owner.rs @@ -34,6 +34,7 @@ mod computer_apps; mod context; mod continuation; mod creation; +mod discovery; mod execution; pub(crate) use access::RunAccess; mod capture; @@ -244,6 +245,7 @@ pub(crate) struct Owner { mcp: crate::mcp::Servers, mcp_projects: Arc, tool_basis: Arc>, + discovery_reads: Vec, editing_tools: Vec<(String, maka_agent_facts::execution::EditingTools)>, skills: Option, pub(super) executors: crate::executor::Executors, @@ -491,6 +493,7 @@ impl Owner { tools, basis: tool_basis, editing: editing_tools, + discovery_reads, } = match prepared_tools { Ok(tools) => tools, Err(error) => { @@ -587,6 +590,7 @@ impl Owner { mcp, mcp_projects, tool_basis: Arc::new(tool_basis), + discovery_reads, editing_tools, skills, executors, @@ -604,6 +608,9 @@ impl Owner { owner, )); } + if let Err(error) = owner.restore_discovery().await { + return Err(initialization::InitializationError::instance(error, owner)); + } let antigravity = owner .settings .external @@ -1240,13 +1247,22 @@ impl Owner { editing: Option, ) -> crate::tools::Declarations { let bot = session.is_some_and(|session| self.bot.is(session)); + let investigation = session.is_some_and(|id| { + self.scheduler + .investigation + .read() + .unwrap() + .as_ref() + .is_some_and(|active| active == id) + }); let excluded: Vec<_> = catalog .definitions() .map(|definition| definition.name.as_str()) .filter(|name| { - let speaks = *name == bot::SEND_USER || *name == bot::SLEEP; - (speaks && !bot) - || (!(speaks && bot) + let bot_only = *name == bot::SEND_USER || *name == bot::SLEEP; + (*name == discovery::COMPLETE && !investigation) + || (bot_only && !bot) + || (!(bot && bot_only) && allowed_tools.is_some_and(|allowed| { !allowed.iter().any(|allowed| allowed.as_str() == *name) })) diff --git a/plugins/agent/native/src/owner/discovery.rs b/plugins/agent/native/src/owner/discovery.rs new file mode 100644 index 0000000000..1b7e2017e3 --- /dev/null +++ b/plugins/agent/native/src/owner/discovery.rs @@ -0,0 +1,840 @@ +/* + * 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. + */ + +//! Discovery owns a scheduler fire; the durable Agent queue owns its execution. +use super::notifications::BackgroundApproval; +use super::*; +use maka_agent_facts::{ + durable::{Fact, Terminal, UnixMillis}, + tool_call::ToolRejection, + tools::*, +}; +use maka_agent_state::{ + Change, Head, + queue::{MessageResolution, PreparedRequest, QueueCommand, QueueEdit, RequestId}, +}; +use maka_plugin_core::storage::{Data, Mutation, Store}; +use maka_scheduler::{ + plan::Fire, + task::{Outcome, Run}, +}; +use std::sync::Weak; +pub(super) const COMPLETE: &str = "DiscoveryComplete"; +const ACTIVE: &str = "discovery/active"; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Report { + /// Nothing worth notifying; record what was checked without waking the Bot. + NoReport { note: String }, + /// Actual findings and their source references, not instructions for the Bot. + Report { text: String }, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Phase { + Investigating { + result: Option, + }, + Delivering { + text: String, + notification: Box, + }, + Stopping, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Job { + fire_id: String, + task_id: String, + bot: String, + worker: String, + request: String, + approval: BackgroundApproval, + recall: bool, + input: MessageInput, + execution: Option, + phase: Phase, +} +#[derive(Serialize, Deserialize)] +struct Completed { + // Older receipts can also contain a copy of the report. + run: Run, +} +fn completed_key(fire: &str) -> String { + format!( + "discovery/completed/{}", + hex::encode(Sha256::digest(fire.as_bytes())) + ) +} +fn report_identity(job: &Job) -> String { + hex::encode(Sha256::digest( + format!("discovery-report/{}", job.fire_id).as_bytes(), + )) +} +fn authorization_revoked(authorized: &Result<(), String>) -> bool { + matches!(authorized, Err(e) if matches!(e.as_str(), "discovery_cancelled" | "scheduler_authorization_changed" | "agent_session_unavailable")) +} +fn reason(error: impl std::fmt::Display) -> String { + error.to_string() +} +fn result_write_error(error: maka_plugin_core::storage::StoreError) -> ToolError { + match error { + maka_plugin_core::storage::StoreError::OutcomeUnknown(message) => { + ToolError::OutcomeUnknown(message) + } + error => ToolError::Failed(error.to_string()), + } +} +fn mutation( + key: String, + revision: Option, + value: &T, +) -> Result { + Ok(Mutation { + key, + expected_revision: revision, + data: Data::Present(serde_json::to_value(value).map_err(reason)?), + }) +} +pub(super) fn permissions() -> WorkspacePermissions { + WorkspacePermissions { + access: WorkspaceAccess::ReadOnly, + request_permissions: false, + } +} + +impl scheduler::Scheduler { + async fn investigation(&self) -> Result<(Option, Option), String> { + let record = self.storage.read(ACTIVE.into()).await.map_err(reason)?; + let revision = record.as_ref().map(|r| r.revision); + let job: Option = record + .and_then(|r| r.data.value().cloned()) + .map(serde_json::from_value) + .transpose() + .map_err(reason)?; + *self.investigation.write().unwrap() = job.as_ref().map(|j| j.worker.clone()); + Ok((revision, job)) + } + async fn save_investigation( + &self, + revision: Option, + job: &Job, + ) -> Result { + let records = self + .storage + .batch(vec![ + mutation(ACTIVE.into(), revision, job) + .map_err(maka_plugin_core::storage::StoreError::Unavailable)?, + ]) + .await?; + *self.investigation.write().unwrap() = Some(job.worker.clone()); + Ok(records[0].revision) + } + async fn has_active_fire(&self, job: &Job) -> Result { + Ok(self.change_basis(&job.task_id).await?.is_some_and(|saved| { + saved.plan.task.status == maka_scheduler::task::Status::Active + && saved.plan.pending.as_ref().is_some_and(|fire| { + fire.id == job.fire_id + && matches!( + fire.authorization, + Some(scheduler::Approval::Discovery { .. }) + ) + }) + })) + } + pub(super) fn discovery_tool(self: &Arc) -> ToolRegistration { + ToolRegistration { + definition: ToolDefinition { + provider: None, name: COMPLETE.into(), + description: "Finish this read-only discovery investigation. Use no_report with a concise note when nothing merits notifying the user; otherwise report concrete findings and source references. This stores the result and ends the Turn. It does not directly send a user message.".into(), + input_schema: serde_json::to_value(schemars::schema_for!(Report)).expect("discovery schema"), + output_schema: None, freeform: None, + }, + nesting: ToolNesting::DirectOnly, + semantics: ToolSemantics::FinishTurn, + handler: ToolHandler::Prepared(self.clone()), + } + } + pub(super) fn prepare_discovery( + &self, + input: Value, + context: ToolCallContext, + cancellation: CancellationToken, + ) -> PreparationFuture { + let owner = self.link.get().and_then(Weak::upgrade); + Box::pin(async move { + let owner = owner.ok_or(ToolRejection::Unavailable)?; + if cancellation.is_cancelled() { + return Err(ToolRejection::Cancelled); + } + let report: Report = + serde_json::from_value(input).map_err(|e| ToolRejection::InvalidInput { + message: e.to_string(), + })?; + let text = match &report { + Report::NoReport { note } => note, + Report::Report { text } => text, + }; + if text.trim().is_empty() { + return Err(ToolRejection::InvalidInput { + message: "Provide a nonempty result or investigation note".into(), + }); + } + Ok(PreparedEffect::new(move |cancel| { + Box::pin(async move { + if cancel.is_cancelled() { + return Err(ToolError::Failed("Discovery cancelled".into())); + } + // An observer cannot discard an accepted result write. + let captured = owner.clone(); + let job = owner + .workers + .spawn(async move { captured.complete_discovery(context, report).await }); + job.await + .map_err(|e| ToolError::OutcomeUnknown(e.to_string()))??; + Ok(serde_json::json!({"recorded":true}).into()) + }) + })) + }) + } +} + +impl Owner { + pub(super) async fn restore_discovery(&self) -> Result<(), String> { + let (revision, job) = self.scheduler.investigation().await?; + let Some(mut job) = job else { + return Ok(()); + }; + let authorized = self.discovery_authorized(&job).await; + let pending = self.scheduler.has_active_fire(&job).await?; + if matches!(job.phase, Phase::Stopping) + || !pending + || authorization_revoked(&authorized) + || self.discovery_worker_retired(&job).await? + { + // Preparation enqueues before saving Delivering. Revoke either crash + // window before activated() can start consuming the durable queue. + self.retract_discovery_report(&job).await?; + if !matches!(job.phase, Phase::Stopping) { + job.phase = Phase::Stopping; + self.scheduler + .save_investigation(revision, &job) + .await + .map_err(reason)?; + } + } else { + authorized?; + } + Ok(()) + } + async fn discovery_worker_retired(&self, job: &Job) -> Result { + match self.store.session_retirement(&job.worker).await { + Ok(retired) => Ok(retired.is_some()), + Err(maka_event_log::StoreError::SessionNotFound) => Ok(false), + Err(error) => Err(reason(error)), + } + } + async fn retract_discovery_report(&self, job: &Job) -> Result<(), String> { + let _ordered = self.runs.ordered(&job.bot).await; + let queue = self.durable.queue(); + let identity = report_identity(job); + if matches!( + queue + .resolve_message(job.bot.clone(), format!("m_{identity}")) + .await + .map_err(reason)?, + MessageResolution::Pending + ) { + let pending = queue.read(job.bot.clone()).await.map_err(reason)?; + queue + .execute( + job.bot.clone(), + QueueCommand { + id: maka_agent_facts::durable::FactId::new(), + at: UnixMillis(now() as i64), + expected_revision: pending.revision, + edit: QueueEdit::Retract { + request: RequestId(identity), + }, + }, + vec![], + ) + .await + .map_err(reason)?; + self.wake_notify.notify_one(); + } + Ok(()) + } + fn discovery_tools(&self, recall: bool) -> Vec { + let mut tools = self.discovery_reads.clone(); + if recall { + tools.extend(["Recall".into(), "RecallMore".into()]); + } + tools.push(COMPLETE.into()); + tools.sort(); + tools.dedup(); + tools + } + async fn discovery_authorized(&self, job: &Job) -> Result<(), String> { + if !self.settings.bot + || !self.settings.scheduled_tasks + || !self.bot.is(&job.bot) + || (job.recall && !self.settings.recall) + { + return Err("discovery_cancelled".into()); + } + let source = self.session(&job.bot).await?; + check_target(&source, &job.approval.target) + } + async fn check_discovery_invocation( + &self, + context: &ToolCallContext, + job: &Job, + ) -> Result<(), String> { + if context.execution.session_id != job.worker + || !matches!(job.phase, Phase::Investigating { .. }) + { + return Err("discovery_wrong_execution".into()); + } + let view = self + .session_run(&job.worker, context.execution.run_id) + .await? + .ok_or("discovery_execution_unavailable")?; + if view.spec.turn_id != context.execution.turn_id || view.stopped || view.terminal.is_some() + { + return Err("discovery_execution_closed".into()); + } + if !matches!(self.durable.queue().resolve_message(job.worker.clone(), format!("m_{}",job.request)).await.map_err(reason)?, MessageResolution::Delivered {run, shared:false} if run == context.execution.run_id) + { + return Err("discovery_execution_changed".into()); + } + self.discovery_authorized(job).await?; + if !self.scheduler.has_active_fire(job).await? { + return Err("discovery_cancelled".into()); + } + Ok(()) + } + async fn complete_discovery( + &self, + context: ToolCallContext, + report: Report, + ) -> Result<(), ToolError> { + let _gate = self.scheduler.gate.lock().await; + let (revision, job) = self + .scheduler + .investigation() + .await + .map_err(ToolError::Failed)?; + let mut job = job.ok_or_else(|| ToolError::Failed("discovery_not_active".into()))?; + self.check_discovery_invocation(&context, &job) + .await + .map_err(ToolError::Failed)?; + let Phase::Investigating { result } = &mut job.phase else { + unreachable!() + }; + if let Some(previous) = result { + return if *previous == report { + Ok(()) + } else { + Err(ToolError::Failed("discovery_result_conflict".into())) + }; + } + *result = Some(report); + job.execution = Some(context.execution); + self.scheduler + .save_investigation(revision, &job) + .await + .map_err(result_write_error)?; + self.scheduler.wake.notify_one(); + Ok(()) + } + pub(super) async fn dispatch_discovery( + self: &Arc, + fire: &Fire, + enabled: bool, + ) -> Result, String> { + if let Some(record) = self + .scheduler + .storage + .read(completed_key(&fire.id)) + .await + .map_err(reason)? + && let Some(value) = record.data.value() + { + return Ok(Some( + serde_json::from_value::(value.clone()) + .map_err(reason)? + .run, + )); + } + let (mut revision, current) = self.scheduler.investigation().await?; + if current.as_ref().is_some_and(|j| j.fire_id != fire.id) { + return Ok(None); + } + let mut job = if let Some(job) = current { + job + } else { + let Some( + scheduler::Approval::Discovery { approval, recall } + | scheduler::Approval::CancelledDiscovery { approval, recall }, + ) = &fire.authorization + else { + return Err("discovery_approval_missing".into()); + }; + let request = hex::encode(Sha256::digest(fire.id.as_bytes())); + let worker = format!("{}{}", self.prefix, &request[..32]); + let recall = *recall; + let input: MessageInput = match approval.language { + Language::ZhCn => format!("执行一次已授权的只读调查:\n{}\n\n仅检查本次任务相关且已授权的材料。不要修改文件、扩大权限或安排后续任务。结束时必须调用 DiscoveryComplete:没有值得通知的发现用 no_report,附简短调查记录;有发现用 report,说明实际依据与来源。普通回复不代表已提交结果。", fire.intent.body()), + Language::ZhTw => format!("執行一次已授權的唯讀調查:\n{}\n\n只檢查本次任務相關且已授權的資料。不要修改檔案、擴大權限或安排後續任務。結束時必須呼叫 DiscoveryComplete:沒有值得通知的發現用 no_report,附簡短調查記錄;有發現用 report,說明實際依據與來源。一般回覆不代表已提交結果。", fire.intent.body()), + _ => format!("Perform one authorized read-only investigation:\n{}\n\nInspect only relevant, authorized material. Do not modify files, expand permissions or schedule more work. Finish with DiscoveryComplete: no_report with a concise investigation note when there is nothing worth notifying; report with concrete findings and source references otherwise. Ordinary text is not a submitted result.", fire.intent.body()), + }.into(); + let job = Job { + fire_id: fire.id.clone(), + task_id: fire.task_id.clone(), + bot: approval.target.session_id.clone(), + worker, + request, + approval: approval.clone(), + recall, + input, + execution: None, + phase: Phase::Investigating { result: None }, + }; + revision = Some( + self.scheduler + .save_investigation(revision, &job) + .await + .map_err(reason)?, + ); + job + }; + if self.discovery_worker_retired(&job).await? { + self.retract_discovery_report(&job).await?; + job.phase = Phase::Stopping; + return self + .settle_discovery( + fire, + revision, + &job, + Outcome::Blocked, + "Investigation Session retired", + ) + .await + .map(Some); + } + let authorized = self.discovery_authorized(&job).await; + if matches!(job.phase, Phase::Stopping) + || !enabled + || matches!( + fire.authorization, + Some(scheduler::Approval::CancelledDiscovery { .. }) + ) + || authorization_revoked(&authorized) + || matches!( + self.durable + .queue() + .resolve_message(job.bot.clone(), format!("m_{}", report_identity(&job))) + .await + .map_err(reason)?, + MessageResolution::Cancelled + ) + { + self.retract_discovery_report(&job).await?; + if !matches!(job.phase, Phase::Stopping) { + job.phase = Phase::Stopping; + revision = Some( + self.scheduler + .save_investigation(revision, &job) + .await + .map_err(reason)?, + ); + } + } else { + authorized?; + } + if let Phase::Delivering { notification, .. } = &job.phase + && self.admit_notification(notification).await? + { + return self + .settle_discovery(fire, revision, &job, Outcome::Ok, "Report admitted to Bot") + .await + .map(Some); + } + let queue = self.durable.queue(); + let message = format!("m_{}", job.request); + let resolution = queue + .resolve_message(job.worker.clone(), message.clone()) + .await + .map_err(reason)?; + match resolution { + MessageResolution::Missing => { + if matches!(job.phase, Phase::Stopping) { + return self + .settle_discovery( + fire, + revision, + &job, + Outcome::Blocked, + "Investigation cancelled before admission", + ) + .await + .map(Some); + } + if self + .store + .get_session::(&job.worker) + .await + .map_err(reason)? + .is_none() + { + let source = self.session(&job.bot).await?; + self.store + .create_session( + &job.worker, + &job.request, + &StoredSessionConfiguration { + parent_session_id: Some(job.bot.clone()), + backend: Some(BackendPreference::Model { + connection_id: job.approval.model.connection_id.clone(), + model_id: job.approval.model.model_id.clone(), + thinking_level: job.approval.thinking_level, + }), + compaction_prompt: None, + applied_preset: None, + collaboration_mode: CollaborationMode::Agent, + tools: Some(self.discovery_tools(job.recall)), + title: fire.title.clone(), + title_is_manual: true, + pinned: false, + labels: vec![], + workspace: source + .workspace_binding + .as_ref() + .ok_or("discovery_workspace_unavailable")? + .reference(), + permissions: permissions(), + boundary_revision: 0, + }, + fire.scheduled_at as u64, + ) + .await + .map_err(reason)?; + } + let native = self + .services + .request( + maka_capabilities::call::Identity::Native, + self.stop.child_token(), + ) + .map_err(reason)?; + let captured = self + .capture_current_run( + &job.worker, + Some(BackendPreference::Model { + connection_id: job.approval.model.connection_id.clone(), + model_id: job.approval.model.model_id.clone(), + thinking_level: job.approval.thinking_level, + }), + job.request.clone(), + native.scope(), + ) + .await; + let cleanup = native.finish().await; + let mut basis = captured.map_err(String::from)?; + cleanup?; + if !job.approval.accepted_model.same_execution_route( + basis.model.as_ref().ok_or("discovery_model_unavailable")?, + ) { + return Err("background_model_route_changed".into()); + } + basis.model = Some(job.approval.accepted_model.clone()); + basis.background = Some(job.approval.target.clone()); + basis.background_sandbox_ceiling = job.approval.sandbox_ceiling.clone(); + basis.background_kind = BackgroundKind::Schedule { + task_id: job.task_id.clone(), + }; + let source = RootSourceMessage { + message: DeliveredMessage { + message_id: message, + origin: None, + submitted_content_digest: job.input.content_digest().map_err(reason)?, + content: job.input.clone(), + }, + unprepared_content: job.input.clone(), + submitted_placement: Placement::CurrentTurn, + disposition: MessageDisposition::TurnStarted, + submitted_intent: None, + }; + self.enqueue_background( + &job.worker, + source, + &basis, + &job.request, + Default::default(), + maka_agent_state::queue::Placement::Followup, + false, + ) + .await?; + return Ok(None); + } + MessageResolution::Pending => { + if matches!(job.phase, Phase::Stopping) { + let _ordered = self.runs.ordered(&job.worker).await; + if matches!( + queue + .resolve_message(job.worker.clone(), message) + .await + .map_err(reason)?, + MessageResolution::Pending + ) { + let pending = queue.read(job.worker.clone()).await.map_err(reason)?; + queue + .execute( + job.worker.clone(), + QueueCommand { + id: maka_agent_facts::durable::FactId::new(), + at: UnixMillis(now() as i64), + expected_revision: pending.revision, + edit: QueueEdit::Retract { + request: RequestId(job.request.clone()), + }, + }, + vec![], + ) + .await + .map_err(reason)?; + self.wake_notify.notify_one(); + } + } + return Ok(None); + } + MessageResolution::Cancelled => { + return self + .settle_discovery( + fire, + revision, + &job, + Outcome::Blocked, + "Investigation cancelled", + ) + .await + .map(Some); + } + MessageResolution::Delivered { run, shared } => { + if shared { + return Err("discovery_shared_execution".into()); + } + let view = self + .session_run(&job.worker, run) + .await? + .ok_or("discovery_execution_unavailable")?; + let execution = ExecutionIdentity { + session_id: job.worker.clone(), + turn_id: view.spec.turn_id.clone(), + run_id: run, + }; + if job.execution.as_ref() != Some(&execution) { + job.execution = Some(execution); + revision = Some( + self.scheduler + .save_investigation(revision, &job) + .await + .map_err(reason)?, + ); + } + if matches!(job.phase, Phase::Stopping) && view.terminal.is_none() && !view.stopped + { + self.durable + .control( + run, + Head::from(&view), + vec![Change::new( + Fact::StopRequested { + diagnostic: "discovery_cancelled".into(), + }, + UnixMillis(now() as i64), + )], + vec![], + ) + .await + .map_err(reason)?; + self.wake_notify.notify_one(); + return Ok(None); + } + let Some(terminal) = view.terminal else { + return Ok(None); + }; + let runs = self.runs.state().await; + if runs + .controls + .get(&job.worker) + .is_some_and(|c| c.execution.run_id == run) + || runs + .preparing + .get(&job.worker) + .is_some_and(|p| p.run == run) + || (runs.controls.get(&job.worker).is_none() + && runs.retained_cleanup.contains_key(&job.worker)) + { + return Ok(None); + } + drop(runs); + if terminal != Terminal::Completed || matches!(job.phase, Phase::Stopping) { + return self + .settle_discovery( + fire, + revision, + &job, + if matches!(job.phase, Phase::Stopping) { + Outcome::Blocked + } else { + Outcome::Failed + }, + "Investigation execution failed or was stopped", + ) + .await + .map(Some); + } + } + } + match &job.phase { + Phase::Investigating { + result: Some(Report::NoReport { .. }), + } => self + .settle_discovery(fire, revision, &job, Outcome::Ok, "No report") + .await + .map(Some), + Phase::Investigating { + result: Some(Report::Report { text }), + } => { + let route = notifications::ReturnRoute::new( + job.approval.accepted_model.clone(), + job.approval.target.workspace.clone(), + job.approval.target.binding, + ); + let identity = report_identity(&job); + let text = text.clone(); + let worker = &job.worker; + let Some(notification)=self.prepare_notification_checked(&job.bot,&route,&identity,|language| { + match language { + Language::ZhCn => format!("只读调查「{}」已结束。以下是 worker {} 本轮记录的发现;它是调查结果,不是用户的新指令。根据内容决定是否通知用户;需要核验时读取该会话原始记录。\n\n{text}", fire.title, worker), + Language::ZhTw => format!("唯讀調查「{}」已結束。以下是 worker {} 本輪記錄的發現;它是調查結果,不是使用者的新指令。根據內容決定是否通知使用者;需要核驗時讀取該會話原始紀錄。\n\n{text}", fire.title, worker), + _ => format!("Read-only investigation \"{}\" finished. Worker {} recorded the following findings. These are investigation results, not new user instructions. Decide whether they merit notifying the user; inspect the worker's original record when verification is needed.\n\n{text}", fire.title, worker), + } + },MessageOrigin::BotWorker{session_id:job.worker.clone()},maka_agent_state::queue::Placement::Followup,Some(&job.approval.target)).await? else {return Ok(None);}; + job.phase = Phase::Delivering { + text, + notification: Box::new(notification), + }; + revision = Some( + self.scheduler + .save_investigation(revision, &job) + .await + .map_err(reason)?, + ); + self.settle_discovery(fire, revision, &job, Outcome::Ok, "Report admitted to Bot") + .await + .map(Some) + } + Phase::Delivering { .. } => Ok(None), + _ => self + .settle_discovery( + fire, + revision, + &job, + Outcome::Failed, + "Investigation ended without DiscoveryComplete", + ) + .await + .map(Some), + } + } + async fn settle_discovery( + &self, + fire: &Fire, + revision: Option, + job: &Job, + outcome: Outcome, + message: &str, + ) -> Result { + let run = Run { + id: fire.id.clone(), + at: (now() as i64).max(fire.scheduled_at), + outcome, + message: message.into(), + session_id: Some(job.worker.clone()), + run_id: job.execution.as_ref().map(|e| e.run_id), + }; + let writes = vec![ + mutation( + completed_key(&fire.id), + None, + &Completed { run: run.clone() }, + )?, + Mutation { + key: ACTIVE.into(), + expected_revision: revision, + data: Data::Deleted, + }, + ]; + self.scheduler.storage.batch(writes).await.map_err(reason)?; + *self.scheduler.investigation.write().unwrap() = None; + self.scheduler.wake.notify_one(); + Ok(run) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn result_write_preserves_commit_uncertainty() { + use maka_plugin_core::storage::StoreError as StorageError; + assert!(matches!( + result_write_error(StorageError::OutcomeUnknown("acknowledgement lost".into())), + ToolError::OutcomeUnknown(_) + )); + assert!(matches!( + result_write_error(StorageError::Conflict { + expected: "1".into(), + actual: "2".into() + }), + ToolError::Failed(_) + )); + } +} + +pub(super) fn check_target( + session: &SessionRecord, + target: &ScheduledTarget, +) -> Result<(), String> { + if session.archived + || session.configuration.workspace != target.workspace + || session.configuration.permissions != target.permissions + || session.configuration.boundary_revision != target.boundary_revision + || session.configuration.binding != target.binding + { + Err("scheduler_authorization_changed".into()) + } else { + Ok(()) + } +} diff --git a/plugins/agent/native/src/owner/driver/tests.rs b/plugins/agent/native/src/owner/driver/tests.rs index 1265e8301b..ffd199d2c0 100644 --- a/plugins/agent/native/src/owner/driver/tests.rs +++ b/plugins/agent/native/src/owner/driver/tests.rs @@ -464,6 +464,10 @@ impl Rig { "queue", "openBot", "botOutbox", + "schedule", + "schedules", + "selectBackend", + "stop", ] } else { &[ @@ -2909,6 +2913,7 @@ mod source_recovery { mod model_recovery { mod bot_tools; + mod discovery; mod predictions; mod preset_acceptance; mod presets; diff --git a/plugins/agent/native/src/owner/driver/tests/model_recovery/discovery.rs b/plugins/agent/native/src/owner/driver/tests/model_recovery/discovery.rs new file mode 100644 index 0000000000..9661fbb45f --- /dev/null +++ b/plugins/agent/native/src/owner/driver/tests/model_recovery/discovery.rs @@ -0,0 +1,788 @@ +/* + * 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::*; +use maka_agent_api::session::StoredSessionConfiguration; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +#[path = "../../../tests/http_fixture.rs"] +mod http_fixture; + +struct Fixture { + rig: Rig, + boot: Boot, + bot: String, + model: Value, +} +impl Fixture { + async fn new(listener: &TcpListener) -> Self { + let mut rig = Rig::new().unwrap(); + let base = rig.directory.path().to_path_buf(); + let mut boot = bootstrap( + &base, + format!("http://{}/v1", listener.local_addr().unwrap()), + false, + ) + .await + .unwrap(); + let store = root(&base, false).await.unwrap(); + let loaded = store + .load_application(boot.deployment) + .await + .unwrap() + .unwrap(); + let (source, packages, _, _) = loaded.into_parts(); + let mut input = source.input; + let config = &mut input + .instances + .get_mut("agent") + .unwrap() + .configuration + .values; + config.insert( + "workspaceRoots".into(), + json!([{"label":"source","path":base.join("workspace")}]), + ); + config.insert("bot".into(), json!(true)); + config.insert("scheduledTasks".into(), json!(true)); + config.insert("recall".into(), json!(true)); + store + .commit_application( + boot.deployment, + source.reference.revision, + input, + source.remote_exports, + packages, + ) + .await + .unwrap(); + boot.source = serde_json::to_value( + store + .load_application(boot.deployment) + .await + .unwrap() + .unwrap() + .source(), + ) + .unwrap(); + store.shutdown().await.unwrap(); + drop(store); + attach(&mut rig, &boot).await.unwrap(); + let client = rig.client.as_ref().unwrap(); + let catalog = call(client, "settings", "connections", json!({"kind":"start"})) + .await + .unwrap(); + let connection=call(client,"settings","create",json!({"expectedCatalogRevision":catalog["revision"],"connection":{"slug":"discovery","name":"Discovery fixture","provider":{"packageId":"maka.providers","entryId":"maka.providers","scope":"profile","name":"ollama"},"configuration":{"baseUrl":boot.base_url},"enabled":true,"enabledModelIds":["llama3.2"],"modelOverrides":{"llama3.2":{"codeMode":false,"apiProtocol":"openai-chat-completions"}}}})).await.unwrap(); + assert_eq!(connection["kind"], "committed"); + let model = json!({"kind":"model","connectionId":connection["connection"]["connectionId"],"modelId":"llama3.2","thinkingLevel":null}); + let workspace = json!({"kind":"directory","path":base.join("workspace")}); + let opened = call(client, "agent", "openBot", json!({"workspace":workspace})) + .await + .unwrap(); + assert!(opened["sessionId"].is_string(), "openBot: {opened}"); + let bot = opened["sessionId"].as_str().unwrap().to_string(); + call( + client, + "agent", + "selectBackend", + json!({"sessionId":bot,"backend":model}), + ) + .await + .unwrap(); + Self { + rig, + boot, + bot, + model, + } + } + fn owner(&self) -> Arc { + self.rig.captured.lock().unwrap().as_ref().unwrap().clone() + } + async fn query(&self, method: &str, input: Value) -> Value { + call(self.rig.client.as_ref().unwrap(), "agent", method, input) + .await + .unwrap_or_else(|e| panic!("{method}: {e}")) + } + async fn create(&self, title: &str) -> String { + self.create_scheduled(title, json!({"kind":"interval","everySeconds":3600,"startAt":maka_agent_authority::now()+3_600_000})).await + } + async fn create_scheduled(&self, title: &str, schedule: Value) -> String { + let config = self.owner().session(&self.bot).await.unwrap().configuration; + let result=self.query("schedule",json!({"kind":"create","input":{"task":{"title":title,"intentBody":format!("Check authorized sources for {title}"),"schedule":schedule,"effect":{"kind":"discovery","target":{"sessionId":self.bot,"workspace":config.workspace,"permissions":config.permissions,"boundaryRevision":config.boundary_revision,"binding":config.binding},"model":{"connectionId":self.model["connectionId"],"modelId":"llama3.2"},"thinkingLevel":null},"maxFires":null,"expiresAt":null},"timezone":"UTC","misfire":"latest"}})).await; + assert_eq!(result["kind"], "committed", "{result}"); + result["taskId"].as_str().unwrap().to_owned() + } + async fn detail(&self, task: &str) -> Value { + self.query("schedules", json!({"kind":"get","taskId":task})) + .await["detail"] + .clone() + } + async fn change(&self, task: &str, kind: &str) { + tokio::time::timeout(Duration::from_secs(45), async { + loop { + let d = self.detail(task).await; + let r = self + .query( + "schedule", + json!({"kind":kind,"taskId":task,"revision":d["revision"]}), + ) + .await; + match r["kind"].as_str() { + Some("committed") => return, + // Settlement can advance the plan revision between get and change. + Some("conflict") => tokio::task::yield_now().await, + _ => panic!("{kind} {task}: {r}"), + } + } + }) + .await + .expect("schedule change did not settle"); + } + async fn settled(&self, task: &str, count: usize) -> Value { + tokio::time::timeout(Duration::from_secs(45), async { + loop { + let d = self.detail(task).await; + if d["task"]["runs"].as_array().unwrap().len() == count { + return d; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap() + } + async fn send(&self, text: &str) -> Value { + self.query( + "send", + json!({"sessionId":self.bot,"message":text,"backend":self.model,"delivery":"queue"}), + ) + .await + } + async fn outbox(&self) -> Value { + self.query("botOutbox", json!({})).await["messages"].clone() + } + async fn quiescent(&self, session: &str) { + let owner = self.owner(); + loop { + if owner + .durable + .session_quiescence(owner.run_owner(), session.to_owned()) + .await + .unwrap() + .is_quiescent() + && !owner.runs.state().await.busy(session) + { + return; + } + tokio::task::yield_now().await; + } + } + async fn close(&mut self) { + let errors = self.rig.finish().await; + assert!(errors.is_empty(), "{errors:?}"); + } + async fn setting(&mut self, key: &str, value: Value) { + self.close().await; + self.rig.captured.lock().unwrap().take(); + self.rig.client = None; + self.rig.host = None; + self.rig.store = None; + let store = root(self.rig.directory.path(), false).await.unwrap(); + let loaded = store + .load_application(self.boot.deployment) + .await + .unwrap() + .unwrap(); + let (source, packages, _, _) = loaded.into_parts(); + let mut input = source.input; + input + .instances + .get_mut("agent") + .unwrap() + .configuration + .values + .insert(key.into(), value); + store + .commit_application( + self.boot.deployment, + source.reference.revision, + input, + source.remote_exports, + packages, + ) + .await + .unwrap(); + self.boot.source = serde_json::to_value( + store + .load_application(self.boot.deployment) + .await + .unwrap() + .unwrap() + .source(), + ) + .unwrap(); + store.shutdown().await.unwrap(); + drop(store); + self.rig.stop_serving = CancellationToken::new(); + attach(&mut self.rig, &self.boot).await.unwrap(); + } + async fn reopen(&mut self) { + self.close().await; + self.rig.captured.lock().unwrap().take(); + self.rig.client = None; + self.rig.host = None; + self.rig.store = None; + self.rig.stop_serving = CancellationToken::new(); + attach(&mut self.rig, &self.boot).await.unwrap(); + } + async fn change_source(&mut self, update: fn(&mut StoredSessionConfiguration)) { + self.close().await; + self.rig.captured.lock().unwrap().take(); + self.rig.client = None; + self.rig.host = None; + self.rig.store = None; + let store = root(self.rig.directory.path(), false).await.unwrap(); + let session = store + .get_session::(&self.bot) + .await + .unwrap() + .unwrap(); + store + .update_session_metadata( + &self.bot, + session.revision, + move |config: &mut StoredSessionConfiguration| { + update(config); + Ok(()) + }, + ) + .await + .unwrap(); + store.shutdown().await.unwrap(); + drop(store); + self.rig.stop_serving = CancellationToken::new(); + attach(&mut self.rig, &self.boot).await.unwrap(); + } +} +async fn next(listener: &TcpListener) -> (TcpStream, Value) { + tokio::time::timeout(Duration::from_secs(45), async { + let (mut socket, _) = listener.accept().await.unwrap(); + let (_, body) = http_fixture::read_request(&mut socket).await; + (socket, body) + }) + .await + .unwrap() +} +async fn tool(socket: &mut TcpStream, name: &str, input: Value) { + let body = format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + json!({"id":"fixture","object":"chat.completion.chunk","created":1,"model":"llama3.2","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":format!("call_{}",Uuid::new_v4()),"type":"function","function":{"name":name,"arguments":input.to_string()}}]},"finish_reason":null}]}), + json!({"id":"fixture","object":"chat.completion.chunk","created":1,"model":"llama3.2","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}}) + ); + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); +} +async fn finish_text(socket: &mut TcpStream, text: &str) { + let body = format!( + "data: {}\n\ndata: [DONE]\n\n", + json!({"id":"plain","object":"chat.completion.chunk","created":1,"model":"llama3.2","choices":[{"index":0,"delta":{"role":"assistant","content":text},"finish_reason":"stop"}]}) + ); + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); +} +fn output(body: &Value) -> Value { + let text = body["messages"] + .as_array() + .unwrap() + .iter() + .rev() + .find(|m| m["role"] == "tool") + .unwrap()["content"] + .as_str() + .unwrap(); + serde_json::from_str(text).unwrap_or(json!({"error":text})) +} +async fn quiet(f: &Fixture, listener: &TcpListener, socket: &mut TcpStream) { + finish_text(socket, "Investigation received.").await; + tokio::time::timeout(Duration::from_secs(45), f.quiescent(&f.bot)) + .await + .expect("Bot Run did not settle before Host teardown"); + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_is_silent_read_only_and_uses_a_fresh_worker_after_restart() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + f.setting("toolDiscovery", json!(true)).await; + let task = f.create("Read-only checks").await; + let mut previous = None; + for count in 1..=2 { + f.change(&task, "trigger_now").await; + let (mut socket, body) = next(&listener).await; + let names: Vec<_> = body["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["function"]["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"DiscoveryComplete")); + assert!( + !names.iter().any(|name| [ + "SendUser", + "sleep", + "ScheduledTask", + "Bash", + "SessionCreate", + "tool_search" + ] + .contains(name)), + "{names:?}" + ); + tool( + &mut socket, + "DiscoveryComplete", + json!({"kind":"no_report","note":"No actionable change"}), + ) + .await; + let done = f.settled(&task, count).await; + assert_eq!(done["task"]["runs"][0]["outcome"], "ok"); + let worker = done["task"]["runs"][0]["sessionId"] + .as_str() + .unwrap() + .to_owned(); + assert_ne!(Some(&worker), previous.as_ref()); + let config = f.owner().session(&worker).await.unwrap().configuration; + assert_eq!(config.permissions, crate::owner::discovery::permissions()); + assert_eq!(config.parent_session_id.as_deref(), Some(f.bot.as_str())); + assert_eq!(f.outbox().await, json!([])); + previous = Some(worker); + f.reopen().await; + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); + } + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_one_shot_timer_runs_without_a_manual_trigger() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let task = f + .create_scheduled( + "One check", + json!({"kind":"once","runAt":maka_agent_authority::now()+2_000}), + ) + .await; + let (mut socket, _) = next(&listener).await; + tool( + &mut socket, + "DiscoveryComplete", + json!({"kind":"no_report","note":"Timed investigation"}), + ) + .await; + let done = f.settled(&task, 1).await; + assert_eq!(done["task"]["status"], "completed"); + assert_eq!(done["task"]["runs"][0]["outcome"], "ok"); + assert_eq!(f.outbox().await, json!([])); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_plain_completion_does_not_count_as_a_submitted_result() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let task = f.create("Missing submission").await; + f.change(&task, "trigger_now").await; + let (mut socket, _) = next(&listener).await; + finish_text(&mut socket, "Done").await; + let done = f.settled(&task, 1).await; + assert_eq!(done["task"]["runs"][0]["outcome"], "failed"); + assert_eq!(f.outbox().await, json!([])); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_pause_resume_cancels_only_the_original_fire_before_next_worker() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let first = f.create("A").await; + let second = f.create("B").await; + f.change(&first, "trigger_now").await; + let (_first_socket, _) = next(&listener).await; + f.change(&second, "trigger_now").await; + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); + f.change(&first, "pause").await; + f.change(&first, "resume").await; + assert_eq!( + f.settled(&first, 1).await["task"]["runs"][0]["outcome"], + "blocked" + ); + let (mut socket, _) = next(&listener).await; + tool( + &mut socket, + "DiscoveryComplete", + json!({"kind":"no_report","note":"B completed"}), + ) + .await; + f.settled(&second, 1).await; + assert_eq!(f.outbox().await, json!([])); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn disabling_scheduling_settles_the_original_worker_without_replay() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let task = f.create("Withdraw existing work").await; + f.change(&task, "trigger_now").await; + let (_socket, _) = next(&listener).await; + f.setting("scheduledTasks", json!(false)).await; + let done = f.settled(&task, 1).await; + assert_eq!(done["task"]["runs"][0]["outcome"], "blocked"); + f.setting("scheduledTasks", json!(true)).await; + f.change(&task, "trigger_now").await; + let (mut socket, _) = next(&listener).await; + tool( + &mut socket, + "DiscoveryComplete", + json!({"kind":"no_report","note":"new investigation"}), + ) + .await; + let next_done = f.settled(&task, 2).await; + assert_ne!( + done["task"]["runs"][0]["sessionId"], + next_done["task"]["runs"][0]["sessionId"] + ); + assert_eq!(f.outbox().await, json!([])); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_restart_settles_an_interrupted_original_run_without_replay() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let task = f.create("Interrupted investigation").await; + f.change(&task, "trigger_now").await; + let (_socket, _) = next(&listener).await; + let worker = f + .owner() + .scheduler + .investigation + .read() + .unwrap() + .clone() + .unwrap(); + f.reopen().await; + let original = f + .owner() + .durable + .latest_run(worker.clone()) + .await + .unwrap() + .unwrap(); + f.query("stop", json!({"sessionId":worker,"runId":original})) + .await; + let done = f.settled(&task, 1).await; + assert_eq!(done["task"]["runs"][0]["outcome"], "failed"); + assert_eq!(done["task"]["runs"][0]["sessionId"], worker); + assert_eq!(done["task"]["runs"][0]["runId"], json!(original)); + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_recovery_rechecks_notification_authority() { + for (setting, after_commit) in [ + ("recall", true), + ("bot", true), + ("scheduledTasks", true), + ("recall", false), + ("permissions", true), + ("boundary_revision", true), + ] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + // Keep the Bot occupied so the real report remains queued across the crash. + f.send("Wait for my next instruction").await; + let (_bot_socket, _) = next(&listener).await; + let task = f.create("Recovered report authorization").await; + f.change(&task, "trigger_now").await; + let (mut worker, _) = next(&listener).await; + let committed = if after_commit { + f.owner().scheduler.storage.unknown_after("delivering") + } else { + f.owner().scheduler.storage.unknown_before("delivering") + }; + tool( + &mut worker, + "DiscoveryComplete", + json!({"kind":"report","text":"recovery-boundary-finding"}), + ) + .await; + tokio::time::timeout(Duration::from_secs(45), committed) + .await + .unwrap() + .unwrap(); + let pending = f.owner().durable.queue().read(f.bot.clone()).await.unwrap(); + assert_eq!(pending.entries.len(), 1); + let report = pending.entries[0].request.clone(); + match setting { + "permissions" => { + f.change_source(|config| { + config.permissions = crate::owner::discovery::permissions() + }) + .await + } + "boundary_revision" => { + f.change_source(|config| config.boundary_revision += 1) + .await + } + _ => f.setting(setting, json!(false)).await, + } + assert_eq!( + f.owner() + .durable + .queue() + .resolve_message(f.bot.clone(), report.message_id.clone()) + .await + .unwrap(), + maka_agent_state::queue::MessageResolution::Cancelled, + "revoked {setting} report survived recovery" + ); + assert_eq!( + f.settled(&task, 1).await["task"]["runs"][0]["outcome"], + "blocked" + ); + f.reopen().await; + assert_eq!( + f.owner() + .durable + .queue() + .resolve_message(f.bot.clone(), report.message_id) + .await + .unwrap(), + maka_agent_state::queue::MessageResolution::Cancelled + ); + f.close().await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_recovery_delivers_an_authorized_notification_once() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + f.send("Wait for my next instruction").await; + let (_bot_socket, _) = next(&listener).await; + // Recovery can admit the report before the next query; stop only the original Run. + let busy = f + .owner() + .durable + .latest_run(f.bot.clone()) + .await + .unwrap() + .unwrap(); + let task = f.create("Authorized recovered report").await; + f.change(&task, "trigger_now").await; + let (mut worker, _) = next(&listener).await; + let committed = f.owner().scheduler.storage.unknown_after("delivering"); + tool( + &mut worker, + "DiscoveryComplete", + json!({"kind":"report","text":"authorized-recovery-finding"}), + ) + .await; + tokio::time::timeout(Duration::from_secs(45), committed) + .await + .unwrap() + .unwrap(); + let pending = f.owner().durable.queue().read(f.bot.clone()).await.unwrap(); + assert_eq!(pending.entries.len(), 1); + let report = pending.entries[0].request.clone(); + // Later user work in the worker Session must not change the investigation's + // captured Run or keep its original report from settling after restart. + let bytes = f + .owner() + .durable + .queue() + .content(f.bot.clone(), report.id.clone(), report.source.clone()) + .await + .unwrap(); + let source: maka_agent_facts::message::RootSourceMessage = + serde_json::from_slice(&bytes).unwrap(); + let Some(maka_agent_facts::message::MessageOrigin::BotWorker { + session_id: worker_id, + }) = source.message.origin + else { + panic!("missing original worker"); + }; + f.query("send", json!({"sessionId":worker_id,"message":"A later independent turn", "backend":f.model,"delivery":"queue"})).await; + let (mut later_worker, _) = next(&listener).await; + finish_text(&mut later_worker, "Independent turn complete.").await; + tokio::time::timeout(Duration::from_secs(45), f.quiescent(&worker_id)) + .await + .expect("independent worker Run did not settle before Host teardown"); + f.reopen().await; + f.query("stop", json!({"sessionId":f.bot,"runId":busy})) + .await; + let (mut bot, body) = next(&listener).await; + assert!( + body.to_string().contains("authorized-recovery-finding"), + "{body}" + ); + quiet(&f, &listener, &mut bot).await; + assert_eq!( + f.settled(&task, 1).await["task"]["runs"][0]["outcome"], + "ok" + ); + let delivered = f + .owner() + .durable + .queue() + .resolve_message(f.bot.clone(), report.message_id.clone()) + .await + .unwrap(); + assert!( + matches!( + delivered, + maka_agent_state::queue::MessageResolution::Delivered { shared: false, .. } + ), + "expected the recovered report to start its own Bot Run, got {delivered:?}" + ); + f.reopen().await; + assert_eq!( + f.owner() + .durable + .queue() + .resolve_message(f.bot.clone(), report.message_id) + .await + .unwrap(), + delivered + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn disabled_bot_blocks_an_approved_discovery_without_calling_a_model() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let task = f.create("Disabled Bot").await; + f.setting("bot", json!(false)).await; + f.change(&task, "trigger_now").await; + assert_eq!( + f.settled(&task, 1).await["task"]["runs"][0]["outcome"], + "blocked" + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); + f.close().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn discovery_model_proposals_require_bot_identity_and_human_approval() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut f = Fixture::new(&listener).await; + let proposal = json!({"request":{"mode":"create","title":"Proposed discovery","intentBody":"Check authorized documents","effect":"discovery","schedule":{"kind":"interval","everySeconds":3600,"startAt":maka_agent_authority::now()+3_600_000},"timezone":"UTC"}}); + let created=f.query("create",json!({"title":"Ordinary proposer","automaticTitle":false,"labels":[],"tools":["ScheduledTask","DiscoveryComplete"],"workspace":{"kind":"directory","path":f.rig.directory.path().join("workspace")}})).await; + let session = created["session"]["id"].as_str().unwrap(); + f.query("send",json!({"sessionId":session,"message":"Propose a discovery","backend":f.model,"delivery":"queue"})).await; + let (mut socket, body) = next(&listener).await; + assert!( + body["tools"] + .as_array() + .unwrap() + .iter() + .all(|tool| tool["function"]["name"] != "DiscoveryComplete") + ); + tool(&mut socket, "ScheduledTask", proposal.clone()).await; + let (mut socket, body) = next(&listener).await; + assert!( + output(&body).to_string().contains("discovery_requires_bot"), + "{body}" + ); + finish_text(&mut socket, "Cannot propose discovery here").await; + f.quiescent(session).await; + assert_eq!( + f.query("schedules", json!({"kind":"discovery","sessionId":f.bot})) + .await["tasks"], + json!([]) + ); + f.send("Propose a discovery").await; + let (mut socket, _) = next(&listener).await; + tool(&mut socket, "ScheduledTask", proposal).await; + let (mut socket, _) = next(&listener).await; + let page = f + .query("schedules", json!({"kind":"discovery","sessionId":f.bot})) + .await; + let task = &page["tasks"][0]; + let id = task["id"].as_str().unwrap(); + assert_eq!(task["status"], "paused"); + assert_eq!(task["approved"], false); + tool( + &mut socket, + "ScheduledTask", + json!({"request":{"mode":"resume","taskId":id,"revision":task["revision"]}}), + ) + .await; + let (mut socket, body) = next(&listener).await; + assert!( + output(&body) + .to_string() + .contains("scheduler_user_approval_required"), + "{body}" + ); + quiet(&f, &listener, &mut socket).await; + f.change(id, "approve").await; + f.change(id, "trigger_now").await; + let (mut worker, _) = next(&listener).await; + tool( + &mut worker, + "DiscoveryComplete", + json!({"kind":"no_report","note":"Checked after user approval"}), + ) + .await; + assert_eq!(f.settled(id, 1).await["task"]["runs"][0]["outcome"], "ok"); + f.close().await; +} diff --git a/plugins/agent/native/src/owner/driver_assembly.rs b/plugins/agent/native/src/owner/driver_assembly.rs index 30e4dce866..233bef3e5a 100644 --- a/plugins/agent/native/src/owner/driver_assembly.rs +++ b/plugins/agent/native/src/owner/driver_assembly.rs @@ -91,8 +91,10 @@ impl Owner { .into()); } }; + let discovery = self.scheduler.investigation.read().unwrap().as_deref() + == Some(view.spec.session_id.as_str()); let external_bridge = matches!(&view.spec.backend, BackendSpec::Executor { selection, .. } if selection.executor == "external"); - let host_tools = model.is_some() || external_bridge; + let host_tools = !discovery && (model.is_some() || external_bridge); let catalog = if !host_tools { self.mcp_projects.base.clone() } else { diff --git a/plugins/agent/native/src/owner/notifications.rs b/plugins/agent/native/src/owner/notifications.rs index 29da9e2f74..a30a9906aa 100644 --- a/plugins/agent/native/src/owner/notifications.rs +++ b/plugins/agent/native/src/owner/notifications.rs @@ -198,11 +198,38 @@ impl Owner { render: impl FnOnce(Language) -> String, origin: MessageOrigin, ) -> Result, String> { - let session_id = &source.session_id; + let placement = if matches!(origin, MessageOrigin::SessionCompletion { .. }) { + QueuePlacement::Completion { + caller: source.run_id, + } + } else { + QueuePlacement::Followup + }; + self.prepare_notification_checked( + &source.session_id, + route, + identity, + render, + origin, + placement, + None, + ) + .await + } + pub(super) async fn prepare_notification_checked( + &self, + session_id: &str, + route: &ReturnRoute, + identity: &str, + render: impl FnOnce(Language) -> String, + origin: MessageOrigin, + placement: QueuePlacement, + boundary: Option<&ScheduledTarget>, + ) -> Result, String> { if let Some(original) = self .durable .queue() - .entry(session_id.clone(), RequestId(identity.into())) + .entry(session_id.to_owned(), RequestId(identity.into())) .await .map_err(|e| e.to_string())? { @@ -256,13 +283,6 @@ impl Owner { } basis.model = Some(model.clone()); } - let placement = if matches!(origin, MessageOrigin::SessionCompletion { .. }) { - QueuePlacement::Completion { - caller: source.run_id, - } - } else { - QueuePlacement::Followup - }; let message: MessageInput = render(basis.language).into(); let source = RootSourceMessage { message: DeliveredMessage { @@ -276,7 +296,7 @@ impl Owner { disposition: MessageDisposition::TurnStarted, submitted_intent: None, }; - self.enqueue_background( + self.enqueue_background_checked( session_id, source, &basis, @@ -284,6 +304,7 @@ impl Owner { Default::default(), placement, false, + boundary, ) .await .map(Some) @@ -322,6 +343,28 @@ impl Owner { } pub(super) async fn enqueue_background( + &self, + session: &str, + source: RootSourceMessage, + basis: &CapturedRun, + key: &str, + required_tools: BTreeSet, + placement: QueuePlacement, + steer: bool, + ) -> Result { + self.enqueue_background_checked( + session, + source, + basis, + key, + required_tools, + placement, + steer, + None, + ) + .await + } + async fn enqueue_background_checked( &self, session: &str, source: RootSourceMessage, @@ -330,6 +373,7 @@ impl Owner { required_tools: BTreeSet, mut placement: QueuePlacement, steer: bool, + boundary: Option<&ScheduledTarget>, ) -> Result { let _ordered = self.runs.ordered(session).await; let queue = self.durable.queue(); @@ -343,6 +387,9 @@ impl Owner { self.validate_background(basis).await?; let current = self.session(session).await?; basis.validate_background_target(¤t)?; + if let Some(boundary) = boundary { + discovery::check_target(¤t, boundary)?; + } let native = self .services .request( diff --git a/plugins/agent/native/src/owner/scheduler.rs b/plugins/agent/native/src/owner/scheduler.rs index 650d4c1a98..e771c796e9 100644 --- a/plugins/agent/native/src/owner/scheduler.rs +++ b/plugins/agent/native/src/owner/scheduler.rs @@ -21,8 +21,12 @@ mod dispatch; mod repository; mod scripts; +#[cfg(test)] +mod storage_fault; mod tools; use super::*; +#[cfg(not(test))] +use maka_host_runtime::storage::Storage; use maka_plugin_core::storage::Data; use maka_plugin_core::storage::Mutation; use maka_plugin_core::storage::Namespace; @@ -33,6 +37,8 @@ use maka_scheduler::{ }; use repository::Saved; use std::sync::{OnceLock, Weak}; +#[cfg(test)] +use storage_fault::Storage; use tokio::sync::Notify; type Plan = TaskPlan; #[derive(Clone, Serialize, Deserialize)] @@ -42,7 +48,31 @@ type Plan = TaskPlan; reason = "Scheduler approval transfers its original record inline without an allocation per observation" )] pub(in crate::owner) enum Approval { - Execution { approval: BackgroundGrant }, + Execution { + approval: BackgroundGrant, + }, + Discovery { + approval: super::notifications::BackgroundApproval, + recall: bool, + }, + CancelledDiscovery { + approval: super::notifications::BackgroundApproval, + recall: bool, + }, +} +fn pause_plan(plan: &Plan, at: i64) -> Plan { + let mut paused = plan.pause(at); + if let Some(fire) = &mut paused.pending + && matches!(fire.effect, ScheduledEffect::Discovery { .. }) + { + fire.authorization = fire.authorization.take().map(|a| match a { + Approval::Discovery { approval, recall } => { + Approval::CancelledDiscovery { approval, recall } + } + other => other, + }); + } + paused } #[derive(Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -101,16 +131,17 @@ impl BackgroundGrant { } pub(crate) struct Scheduler { - storage: Arc, + pub(super) storage: Arc, pub(in crate::owner) gate: Arc>, - wake: Notify, + pub(super) wake: Notify, changed: tokio::sync::watch::Sender, - link: OnceLock>, + pub(super) link: OnceLock>, + pub(super) investigation: std::sync::RwLock>, } impl Scheduler { pub fn new(store: Arc, prefix: String) -> Self { Self { - storage: Arc::new(maka_host_runtime::storage::Storage::new( + storage: Arc::new(Storage::new( store, prefix, Namespace::new( @@ -123,6 +154,7 @@ impl Scheduler { wake: Notify::new(), changed: tokio::sync::watch::channel(0).0, link: OnceLock::new(), + investigation: std::sync::RwLock::new(None), } } pub fn bind(&self, owner: &Arc) { @@ -145,10 +177,22 @@ impl Owner { let scheduler = &self.scheduler; let _gate = scheduler.gate.lock().await; let catalog = scheduler.load().await?; + let discovery_session = match &input { + ScheduleRead::Discovery { session_id, .. } => { + self.session(session_id).await?; + Some(session_id.clone()) + } + _ => None, + }; match input { ScheduleRead::List { cursor, expected_revision, + } + | ScheduleRead::Discovery { + cursor, + expected_revision, + .. } => { let revision = catalog.revision.unwrap_or(0); if expected_revision.is_some_and(|expected| expected != revision) { @@ -160,6 +204,15 @@ impl Owner { let mut tasks = Vec::new(); let mut next_cursor = None; for (id, saved) in &catalog.plans { + let matches_view = match &saved.plan.task.effect { + ScheduledEffect::Discovery { target, .. } => { + discovery_session.as_ref() == Some(&target.session_id) + } + _ => discovery_session.is_none(), + }; + if !matches_view { + continue; + } if cursor.as_ref().is_some_and(|after| id <= after) { continue; } @@ -206,6 +259,7 @@ impl Owner { let id = match &input { ScheduleChange::Create { .. } => format!("task-{}", owner.operation_id(&operation)), ScheduleChange::Update { task_id, .. } + | ScheduleChange::Approve { task_id, .. } | ScheduleChange::Pause { task_id, .. } | ScheduleChange::Resume { task_id, .. } | ScheduleChange::TriggerNow { task_id, .. } @@ -252,30 +306,36 @@ impl Owner { ) -> Result { let sandbox_ceiling = saved .and_then(|saved| saved.plan.authorization.as_ref()) - .and_then(|Approval::Execution { approval }| approval.sandbox_ceiling()); - let authorization = self.approve_schedule(plan, sandbox_ceiling).await?; - let metadata = match &authorization { - Approval::Execution { approval } => { - let target = approval.target(); - let current = self.mutable_session(&target.session_id).await?; - if current.archived - || current.configuration.workspace != target.workspace - || current.configuration.permissions != target.permissions - || current.configuration.boundary_revision != target.boundary_revision - || current.configuration.binding != target.binding - { - return Err("scheduler_authorization_changed".into()); + .and_then(|approval| match approval { + Approval::Execution { approval } => approval.sandbox_ceiling(), + Approval::Discovery { approval, .. } + | Approval::CancelledDiscovery { approval, .. } => { + approval.sandbox_ceiling.as_ref() } - Some( - self.store - .session(&target.session_id) - .await - .map_err(reason)? - .revision_guard(writer, current.revision) - .map_err(reason)?, - ) - } + }); + let authorization = self.approve_schedule(plan, sandbox_ceiling).await?; + let target = match &authorization { + Approval::Execution { approval } => approval.target(), + Approval::Discovery { approval, .. } + | Approval::CancelledDiscovery { approval, .. } => &approval.target, }; + let current = self.mutable_session(&target.session_id).await?; + if current.archived + || current.configuration.workspace != target.workspace + || current.configuration.permissions != target.permissions + || current.configuration.boundary_revision != target.boundary_revision + || current.configuration.binding != target.binding + { + return Err("scheduler_authorization_changed".into()); + } + let metadata = Some( + self.store + .session(&target.session_id) + .await + .map_err(reason)? + .revision_guard(writer, current.revision) + .map_err(reason)?, + ); Ok(PreparedBackgroundGrant { authorization, metadata, @@ -293,6 +353,7 @@ fn expected_revision(input: &ScheduleChange) -> Option { match input { ScheduleChange::Create { .. } => None, ScheduleChange::Update { revision, .. } + | ScheduleChange::Approve { revision, .. } | ScheduleChange::Pause { revision, .. } | ScheduleChange::Resume { revision, .. } | ScheduleChange::TriggerNow { revision, .. } @@ -305,6 +366,7 @@ fn needs_approval(input: &ScheduleChange) -> bool { input, ScheduleChange::Create { .. } | ScheduleChange::Update { .. } + | ScheduleChange::Approve { .. } | ScheduleChange::Resume { .. } ) } @@ -362,7 +424,20 @@ fn schedule_candidate( ) .map_err(reason)? } - ScheduleChange::Pause { .. } => plan()?.pause(at), + ScheduleChange::Approve { .. } => { + let mut next = plan()?.clone(); + if next.pending.is_some() { + return Err("scheduler_trigger_pending".into()); + } + if !matches!(next.task.status, Status::Active | Status::Paused) { + return Err("scheduler_task_terminal".into()); + } + if next.task.status == Status::Paused { + next = next.resume(at).map_err(reason)?; + } + next + } + ScheduleChange::Pause { .. } => pause_plan(plan()?, at), ScheduleChange::Resume { .. } => plan()?.resume(at).map_err(reason)?, ScheduleChange::TriggerNow { .. } => { let mut next = plan()?.clone(); diff --git a/plugins/agent/native/src/owner/scheduler/dispatch.rs b/plugins/agent/native/src/owner/scheduler/dispatch.rs index 579109a71d..c61367404e 100644 --- a/plugins/agent/native/src/owner/scheduler/dispatch.rs +++ b/plugins/agent/native/src/owner/scheduler/dispatch.rs @@ -58,6 +58,11 @@ impl ScriptChecks { } } impl CapturedRun { + fn is_discovery(&self) -> bool { + self.allowed_tools + .as_ref() + .is_some_and(|t| t.iter().any(|n| n == super::super::discovery::COMPLETE)) + } pub(in crate::owner) fn validate_background_target( &self, session: &SessionRecord, @@ -65,7 +70,12 @@ impl CapturedRun { if let Some(boundary) = &self.background && (session.archived || session.configuration.workspace != boundary.workspace - || session.configuration.permissions != boundary.permissions + || session.configuration.permissions + != if self.is_discovery() { + super::super::discovery::permissions() + } else { + boundary.permissions.clone() + } || session.configuration.binding != boundary.binding || session.configuration.boundary_revision != if session.id == boundary.session_id { @@ -126,6 +136,11 @@ impl Owner { thinking_level, .. } + | ScheduledEffect::Discovery { + target, + model, + thinking_level, + } | ScheduledEffect::AgentRun { target, model, @@ -148,6 +163,14 @@ impl Owner { }); } }; + if matches!(plan.task.effect, ScheduledEffect::Discovery { .. }) { + if !self.settings.bot || !self.bot.is(&target.session_id) { + return Err("discovery_requires_bot".into()); + } + if matches!(plan.task.schedule, TaskSchedule::Script { .. }) { + return Err("discovery_requires_time_schedule".into()); + } + } let basis = self .approve_background( target, @@ -159,17 +182,28 @@ impl Owner { sandbox_ceiling, ) .await?; - Ok(Approval::Execution { - approval: BackgroundGrant::Model(super::super::notifications::BackgroundApproval { - target: target.clone(), - model: model.clone(), - accepted_model: basis.model.clone().ok_or("scheduler_model_unavailable")?, - thinking_level: *thinking, - language: basis.language, - sandbox_ceiling: basis.background_sandbox_ceiling, - }), - }) + let approval = super::super::notifications::BackgroundApproval { + target: target.clone(), + model: model.clone(), + accepted_model: basis.model.clone().ok_or("scheduler_model_unavailable")?, + thinking_level: *thinking, + language: basis.language, + sandbox_ceiling: basis.background_sandbox_ceiling, + }; + Ok( + if matches!(plan.task.effect, ScheduledEffect::Discovery { .. }) { + Approval::Discovery { + approval, + recall: self.settings.recall && self.tools.contains("Recall"), + } + } else { + Approval::Execution { + approval: BackgroundGrant::Model(approval), + } + }, + ) } + pub(in crate::owner) async fn approve_background( &self, target: &ScheduledTarget, @@ -304,7 +338,8 @@ impl Owner { } } pub(in crate::owner) fn start_schedules(self: &Arc) { - if !self.settings.scheduled_tasks { + if !self.settings.scheduled_tasks && self.scheduler.investigation.read().unwrap().is_none() + { return; } let owner = self.clone(); @@ -406,6 +441,14 @@ impl Owner { } let saved = &catalog.plans[&id]; let plan = &saved.plan; + if !self.settings.scheduled_tasks + && !plan + .pending + .as_ref() + .is_some_and(|f| matches!(f.effect, ScheduledEffect::Discovery { .. })) + { + continue; + } let retry = plan .pending .as_ref() @@ -413,7 +456,8 @@ impl Owner { if retry.is_some_and(|retry| !retry.ready(Instant::now())) { continue; } - if plan.task.status == Status::Active + if self.settings.scheduled_tasks + && plan.task.status == Status::Active && plan.pending.is_none() && let TaskSchedule::Script { source, .. } = &plan.task.schedule && let Some(Approval::Execution { approval }) = &plan.authorization @@ -428,10 +472,10 @@ impl Owner { let mut plan = plan.clone(); let before = serde_json::to_value(&plan).map_err(reason)?; let at = now() as i64; - if recover { + if recover && self.settings.scheduled_tasks { plan.recover(at).map_err(reason)?; } - if plan.authorization.is_some() { + if self.settings.scheduled_tasks && plan.authorization.is_some() { plan.claim(at).map_err(reason)?; } if serde_json::to_value(&plan).map_err(reason)? != before { @@ -443,7 +487,10 @@ impl Owner { continue; }; let expired = plan.task.expires_at.is_some_and(|expires| at >= expires); - match self.dispatch_scheduled(fire, expired).await { + match self + .dispatch_scheduled(fire, plan.task.status == Status::Active, expired) + .await + { Ok(Some(run)) => { plan.settle(run).map_err(reason)?; self.scheduler @@ -472,8 +519,12 @@ impl Owner { async fn dispatch_scheduled( self: &Arc, fire: &maka_scheduler::plan::Fire, + active: bool, expired: bool, ) -> Result, String> { + if matches!(fire.effect, ScheduledEffect::Discovery { .. }) { + return self.dispatch_discovery(fire, active && !expired).await; + } let Some(Approval::Execution { approval }) = &fire.authorization else { return Ok(Some(settlement( fire, diff --git a/plugins/agent/native/src/owner/scheduler/repository.rs b/plugins/agent/native/src/owner/scheduler/repository.rs index 572674ec0e..5a3d1501de 100644 --- a/plugins/agent/native/src/owner/scheduler/repository.rs +++ b/plugins/agent/native/src/owner/scheduler/repository.rs @@ -117,13 +117,21 @@ fn schedule_budget( { return Err("scheduler_authorization_changed".into()); } - let Some(Approval::Execution { approval }) = &plan.authorization else { - return Err("scheduler_execution_unavailable".into()); + let approval = match &plan.authorization { + Some(Approval::Execution { approval }) => approval.clone(), + Some(Approval::Discovery { approval, .. }) => BackgroundGrant::Model(approval.clone()), + _ => return Err("scheduler_execution_unavailable".into()), }; let fire = plan .pending .as_ref() .ok_or("scheduler_original_fire_missing")?; + if matches!( + fire.authorization, + Some(Approval::CancelledDiscovery { .. }) + ) { + return Err("discovery_cancelled".into()); + } let original_message = format!("m_{}", hex::encode(Sha256::digest(fire.id.as_bytes()))); if original_message != message_id { return Err("scheduler_original_fire_changed".into()); @@ -137,7 +145,7 @@ fn schedule_budget( .task .expires_at .map(maka_agent_facts::durable::UnixMillis), - approval: approval.clone(), + approval, }) } impl Scheduler { @@ -155,7 +163,14 @@ impl Scheduler { schedule_budget(record, id, message_id) } pub(in crate::owner) fn budget_storage(&self) -> Arc { - self.storage.clone() + #[cfg(not(test))] + { + self.storage.clone() + } + #[cfg(test)] + { + self.storage.inner() + } } pub(in crate::owner) fn budget_in_transaction( storage: &maka_host_runtime::storage::Storage, @@ -265,7 +280,7 @@ impl Scheduler { } Ok(catalog) } - pub(super) async fn change_basis(&self, id: &str) -> Result, String> { + pub(in crate::owner) async fn change_basis(&self, id: &str) -> Result, String> { let record = self.storage.read(task_key(id)).await.map_err(reason)?; record .map(|record| decode_saved(record, id)) @@ -565,6 +580,7 @@ impl Saved { title: self.plan.task.title.clone(), session_id: self.plan.task.effect.target().session_id.clone(), script: matches!(self.plan.task.schedule, TaskSchedule::Script { .. }), + approved: self.plan.authorization.is_some(), status: self.plan.task.status, next_fire_at: self.plan.task.next_fire_at, pending_fire: self.plan.pending.as_ref().map(|fire| fire.id.clone()), @@ -577,6 +593,7 @@ impl Saved { task: self.plan.task.clone(), timezone: self.plan.timezone.clone(), misfire: self.plan.misfire, + approved: self.plan.authorization.is_some(), pending_fire: self.plan.pending.as_ref().map(|fire| fire.id.clone()), } } diff --git a/plugins/agent/native/src/owner/scheduler/storage_fault.rs b/plugins/agent/native/src/owner/scheduler/storage_fault.rs new file mode 100644 index 0000000000..8c8220e9b3 --- /dev/null +++ b/plugins/agent/native/src/owner/scheduler/storage_fault.rs @@ -0,0 +1,106 @@ +/* + * 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 maka_event_log::root_store::RootStore; +use maka_plugin_core::storage::{Mutation, Namespace, Record, Store, StoreError}; +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; + +enum Fault { + Unknown(oneshot::Sender<()>, bool), +} + +/// Keep real atomic writes and retirement semantics; only alter their acknowledgement. +pub(in crate::owner) struct Storage { + inner: Arc, + fault: Mutex>, +} +impl std::ops::Deref for Storage { + type Target = maka_host_runtime::storage::Storage; + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl Storage { + pub(super) fn inner(&self) -> Arc { + self.inner.clone() + } + pub(super) fn new(store: Arc, prefix: String, namespace: Namespace) -> Self { + Self { + inner: Arc::new(maka_host_runtime::storage::Storage::new( + store, prefix, namespace, + )), + fault: Mutex::new(None), + } + } + pub(in crate::owner) fn unknown_after(&self, kind: &'static str) -> oneshot::Receiver<()> { + self.unknown_at(kind, true) + } + pub(in crate::owner) fn unknown_before(&self, kind: &'static str) -> oneshot::Receiver<()> { + self.unknown_at(kind, false) + } + fn unknown_at(&self, kind: &'static str, after_commit: bool) -> oneshot::Receiver<()> { + let (sent, observed) = oneshot::channel(); + assert!( + self.fault + .lock() + .unwrap() + .replace((kind, Fault::Unknown(sent, after_commit))) + .is_none() + ); + observed + } + pub(in crate::owner) async fn batch( + &self, + writes: Vec, + ) -> Result, StoreError> { + let fault = { + let mut armed = self.fault.lock().unwrap(); + if armed.as_ref().is_some_and(|(kind, _)| { + writes.iter().any(|write| { + write.data.value().is_some_and(|v| { + v["content"]["kind"] == *kind || v["phase"]["kind"] == *kind + }) + }) + }) { + armed.take().map(|(_, fault)| fault) + } else { + None + } + }; + let unknown = match fault { + Some(Fault::Unknown(observed, after_commit)) => Some((observed, after_commit)), + None => None, + }; + let records = if unknown.as_ref().is_some_and(|(_, after)| !after) { + vec![] + } else { + self.inner.batch(writes).await? + }; + if let Some((observed, _)) = unknown { + // Native Storage retires after OutcomeUnknown; recovery must reopen the owner. + self.inner.finish().await.unwrap(); + observed.send(()).unwrap(); + return Err(StoreError::OutcomeUnknown( + "test: batch acknowledgement lost".into(), + )); + } + Ok(records) + } +} diff --git a/plugins/agent/native/src/owner/scheduler/tools.rs b/plugins/agent/native/src/owner/scheduler/tools.rs index d9a3cf2ed4..28dc50cca4 100644 --- a/plugins/agent/native/src/owner/scheduler/tools.rs +++ b/plugins/agent/native/src/owner/scheduler/tools.rs @@ -65,22 +65,28 @@ enum Target { #[default] Session, AgentRun, + Discovery, } impl Scheduler { pub fn tools(self: &Arc) -> Vec { - vec![ToolRegistration { + let mut tools = vec![ToolRegistration { definition: ToolDefinition { provider: None, name: "ScheduledTask".into(), - description: "Manage this conversation's automations. Creation activates the rule immediately. When it triggers, intentBody is sent to the agent as the message body. For a model connection, the default is steering; choose followup to wait for the next turn, or agent_run for a new conversation. To wake this conversation later, create an automation with the message you want to receive, then end the current turn. For external agents, the trigger starts a new turn in the same conversation. Use a time schedule or a script schedule. Scripts are ordinary JavaScript or TypeScript programs run by Deno from the conversation workspace, under its process sandbox. Scripts can import npm:, jsr:, and node: modules directly. Print true or false on the last stdout line. A script fires when the result becomes true and rearms when false. The default script interval is 60 seconds. Times are Unix milliseconds; timezone is an IANA name. List returns tasks and revisions.".into(), + description: "Manage this conversation's automations. Creation activates the rule immediately. When it triggers, intentBody is sent to the agent as the message body. For a model connection, the default is steering; choose followup to wait for the next turn, or agent_run for a new conversation. To wake this conversation later, create an automation with the message you want to receive, then end the current turn. For external agents, the trigger starts a new turn in the same conversation. Use a time schedule or a script schedule. Scripts are ordinary JavaScript or TypeScript programs run by Deno from the conversation workspace, under its process sandbox. Scripts can import npm:, jsr:, and node: modules directly. Print true or false on the last stdout line. A script fires when the result becomes true and rearms when false. The default script interval is 60 seconds. The Bot may propose a time-scheduled discovery. It runs an independent read-only investigation after the user approves it. Times are Unix milliseconds; timezone is an IANA name. List returns tasks and revisions.".into(), input_schema: serde_json::to_value(schemars::schema_for!(ToolRequest)).expect("task tool schema"), output_schema: None, freeform: None }, nesting: ToolNesting::Nestable, semantics: ToolSemantics::ExclusiveStep, handler: ToolHandler::Prepared(self.clone()), - }] + }]; + tools.push(self.discovery_tool()); + tools } } impl ToolPreparer for Scheduler { fn names(&self) -> Vec { - vec!["ScheduledTask".into()] + vec![ + "ScheduledTask".into(), + super::super::discovery::COMPLETE.into(), + ] } fn prepare( &self, @@ -90,6 +96,9 @@ impl ToolPreparer for Scheduler { cancellation: CancellationToken, ) -> PreparationFuture { let owner = self.link.get().and_then(Weak::upgrade); + if name == super::super::discovery::COMPLETE { + return self.prepare_discovery(input, context, cancellation); + } Box::pin(async move { if name != "ScheduledTask" || owner.is_none() { return Err(ToolRejection::Unavailable); @@ -242,7 +251,7 @@ impl Owner { binding: session.configuration.binding, }; let sandbox_ceiling = Some(view.spec.permission_ceiling.sandbox); - let (effect, approval) = match (&basis.model, &basis.executor) { + match (&basis.model, &basis.executor) { (Some(accepted), None) => { let model = ModelSelection { connection_id: accepted.binding.connection_id.clone(), @@ -250,6 +259,21 @@ impl Owner { }; let thinking_level = accepted.thinking_level; let effect = match effect { + Target::Discovery => { + if !self.settings.bot + || !self.bot.is(&context.execution.session_id) + { + return Err("discovery_requires_bot".into()); + } + if matches!(schedule, TaskSchedule::Script { .. }) { + return Err("discovery_requires_time_schedule".into()); + } + ScheduledEffect::Discovery { + target: target.clone(), + model: model.clone(), + thinking_level, + } + } Target::Session => ScheduledEffect::Session { delivery, target: target.clone(), @@ -262,19 +286,23 @@ impl Owner { thinking_level, }, }; - ( - effect, - BackgroundGrant::Model( - super::super::notifications::BackgroundApproval { - target, - model, - thinking_level, - accepted_model: accepted.clone(), - language: view.spec.language, - sandbox_ceiling, - }, - ), - ) + let approval = if matches!(effect, ScheduledEffect::Discovery { .. }) { + None + } else { + Some(Approval::Execution { + approval: BackgroundGrant::Model( + super::super::notifications::BackgroundApproval { + target, + model, + thinking_level, + accepted_model: accepted.clone(), + language: view.spec.language, + sandbox_ceiling, + }, + ), + }) + }; + (effect, approval) } (None, Some(executor)) if executor.selection.executor == "external" => { if !matches!(effect, Target::Session) { @@ -289,17 +317,18 @@ impl Owner { target: target.clone(), selection: selection.clone(), }, - BackgroundGrant::Executor(ExecutorApproval { - language: view.spec.language, - target, - selection, - sandbox_ceiling, + Some(Approval::Execution { + approval: BackgroundGrant::Executor(ExecutorApproval { + language: view.spec.language, + target, + selection, + sandbox_ceiling, + }), }), ) } _ => return Err("scheduler_backend_unavailable".into()), - }; - (effect, Approval::Execution { approval }) + } }; let id = format!("task-{identity}"); let mut plan = Plan::create( @@ -320,7 +349,10 @@ impl Owner { ) .map_err(reason)?; plan.misfire = misfire; - plan.authorization = Some(authorization); + if authorization.is_none() { + plan = plan.pause(now() as i64); + } + plan.authorization = authorization; (id, Some(plan)) } Input::Pause { @@ -344,15 +376,32 @@ impl Owner { return Err("scheduler_task_outside_conversation".into()); } let plan = match input { - Input::Pause { .. } => Some(saved.plan.pause(now() as i64)), + Input::Pause { .. } => Some(pause_plan(&saved.plan, now() as i64)), Input::Resume { .. } => { let Some(approval) = &saved.plan.authorization else { + if matches!(saved.plan.task.effect, ScheduledEffect::Discovery { .. }) { + return Err("scheduler_user_approval_required".into()); + } return Err("scheduler_execution_unavailable".into()); }; - let Approval::Execution { approval } = approval; - let basis = self - .prepare_scheduled_background(approval, &saved.plan.task.id) - .await?; + let basis = match approval { + Approval::Execution { approval } => { + self.prepare_scheduled_background(approval, &saved.plan.task.id) + .await? + } + Approval::Discovery { approval, .. } => { + self.prepare_approved_background( + approval, + BackgroundKind::Schedule { + task_id: saved.plan.task.id.clone(), + }, + ) + .await? + } + Approval::CancelledDiscovery { .. } => { + return Err("scheduler_execution_unavailable".into()); + } + }; self.validate_background(&basis).await?; Some(saved.plan.resume(now() as i64).map_err(reason)?) } diff --git a/plugins/agent/native/src/owner/send.rs b/plugins/agent/native/src/owner/send.rs index 8c7d875995..e36d71832c 100644 --- a/plugins/agent/native/src/owner/send.rs +++ b/plugins/agent/native/src/owner/send.rs @@ -145,7 +145,8 @@ impl Owner { let mcp_floor = self .capture_sandbox(&session.configuration, parent.sandbox_floor()) .await?; - let selected_catalog = if external && !host_bridge { + let discovery = self.scheduler.investigation.read().unwrap().as_deref() == Some(id); + let selected_catalog = if discovery || (external && !host_bridge) { self.mcp_projects.base.clone() } else { match self @@ -159,7 +160,11 @@ impl Owner { } } }; - let selected_catalog = self.mcp_projects.with_client(id, selected_catalog)?; + let selected_catalog = if discovery { + selected_catalog + } else { + self.mcp_projects.with_client(id, selected_catalog)? + }; if external && !host_bridge && selected_catalog.revision.is_some() { return Err("agent_executor_cannot_use_session_mcp".into()); } @@ -205,7 +210,9 @@ impl Owner { )), collaboration_mode: session.configuration.collaboration_mode, background_kind: BackgroundKind::Interactive, - tool_discovery: (!external || host_bridge) && self.settings.tool_discovery, + tool_discovery: (!external || host_bridge) + && !discovery + && self.settings.tool_discovery, allowed_tools, background: None, background_sandbox_ceiling: None, diff --git a/plugins/agent/native/src/owner/tests/http_fixture.rs b/plugins/agent/native/src/owner/tests/http_fixture.rs new file mode 100644 index 0000000000..bf00ad7633 --- /dev/null +++ b/plugins/agent/native/src/owner/tests/http_fixture.rs @@ -0,0 +1,129 @@ +/* + * 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. + */ + +//! HTTP framing shared by native provider fixtures. Production requests can +//! stream chunked bodies; the fixture still checks complete JSON and exact EOF. +use serde_json::Value; +use tokio::{io::AsyncReadExt, net::TcpStream}; + +pub(super) async fn read_request(socket: &mut TcpStream) -> (String, Value) { + const MAX_BODY_BYTES: usize = 128 * 1024 * 1024; + let mut bytes = Vec::new(); + let boundary = loop { + let mut buffer = [0; 4096]; + let count = socket.read(&mut buffer).await.unwrap(); + assert_ne!( + count, + 0, + "EOF before complete HTTP headers: buffered_bytes={}", + bytes.len() + ); + bytes.extend_from_slice(&buffer[..count]); + if let Some(at) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + assert!( + at < 512 * 1024, + "provider request headers exceed fixture budget" + ); + break at + 4; + } + assert!( + bytes.len() < 512 * 1024, + "provider request headers exceed fixture budget" + ); + }; + let headers = String::from_utf8(bytes[..boundary].to_vec()).unwrap(); + let header = |name: &str| { + headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.trim()) + }; + let body = + if header("transfer-encoding").is_some_and(|value| value.eq_ignore_ascii_case("chunked")) { + let mut body = Vec::new(); + let mut offset = boundary; + loop { + let size_line_end = line_end(socket, &mut bytes, offset).await; + let size = usize::from_str_radix( + std::str::from_utf8(&bytes[offset..size_line_end]) + .unwrap() + .split(';') + .next() + .unwrap(), + 16, + ) + .unwrap(); + offset = size_line_end + 2; + if size == 0 { + // Consume the terminating empty line, including optional trailers, + // before a fixture responds or starts waiting for connection close. + loop { + let end = line_end(socket, &mut bytes, offset).await; + if end == offset { + break; + } + offset = end + 2; + } + break; + } + assert!( + size <= MAX_BODY_BYTES - body.len(), + "provider body exceeds fixture budget" + ); + while bytes.len() < offset + size + 2 { + read_more(socket, &mut bytes).await; + } + body.extend_from_slice(&bytes[offset..offset + size]); + assert_eq!(&bytes[offset + size..offset + size + 2], b"\r\n"); + offset += size + 2; + } + body + } else { + let length: usize = header("content-length") + .expect("request body framing") + .parse() + .unwrap(); + assert!( + length <= MAX_BODY_BYTES, + "provider body exceeds fixture budget" + ); + while bytes.len() < boundary + length { + read_more(socket, &mut bytes).await; + } + bytes[boundary..boundary + length].to_vec() + }; + (headers, serde_json::from_slice(&body).unwrap()) +} + +async fn line_end(socket: &mut TcpStream, bytes: &mut Vec, offset: usize) -> usize { + loop { + if let Some(end) = bytes[offset..].windows(2).position(|pair| pair == b"\r\n") { + return offset + end; + } + read_more(socket, bytes).await; + } +} + +async fn read_more(socket: &mut TcpStream, bytes: &mut Vec) { + let mut buffer = [0; 4096]; + let count = socket.read(&mut buffer).await.unwrap(); + assert_ne!(count, 0, "EOF before complete provider HTTP request"); + bytes.extend_from_slice(&buffer[..count]); +} diff --git a/plugins/agent/native/src/tools.rs b/plugins/agent/native/src/tools.rs index c354f4913b..cdc46bf051 100644 --- a/plugins/agent/native/src/tools.rs +++ b/plugins/agent/native/src/tools.rs @@ -269,6 +269,7 @@ pub(crate) struct Catalog { pub basis: Vec, /// Derived from actual bound contracts and methods, never display names. pub editing: Vec<(String, EditingTools)>, + pub discovery_reads: Vec, } pub(crate) fn catalog( @@ -280,11 +281,13 @@ pub(crate) fn catalog( tools, basis, editing, + discovery_reads, } = catalog_inner(services, selected, additional, None)?; Ok(Catalog { tools: Declarations(tools), basis, editing, + discovery_reads, }) } @@ -321,6 +324,7 @@ fn catalog_inner( run: Option, ) -> Result, String> { let mut basis = Vec::new(); + let mut discovery_reads = Vec::new(); let file_contract = maka_workspace_api::files::contract(); let editing: Vec<_> = selected .iter() @@ -371,6 +375,11 @@ fn catalog_inner( return Err("agent_tool_contract_invalid".to_owned()); }; let contract = binding.contract(); + if contract == &file_contract + && matches!(selection.method.as_str(), "readText" | "readImage" | "glob" | "grep") + { + discovery_reads.push(selection.name.clone()); + } let method = methods .get(&selection.method) .ok_or("agent_tool_method_unavailable")?; @@ -611,6 +620,7 @@ fn catalog_inner( tools: catalog, basis, editing, + discovery_reads, }) } #[derive(Clone)] diff --git a/plugins/agent/state/src/queue/sql.rs b/plugins/agent/state/src/queue/sql.rs index 30dc209e07..07ffb274f1 100644 --- a/plugins/agent/state/src/queue/sql.rs +++ b/plugins/agent/state/src/queue/sql.rs @@ -385,7 +385,12 @@ pub(crate) fn retract_interactive( let mut expected_revision = queue.revision; for entry in queue.entries { let source = source(c, &entry.request)?; - if !is_interactive(&entry.request, &source) { + if !is_interactive(&entry.request, &source) + || matches!( + source.message.origin, + Some(maka_agent_facts::message::MessageOrigin::BotWorker { .. }) + ) + { continue; } let mut hash = Sha256::new(); diff --git a/products/maka/workbench/app/Automations.tsx b/products/maka/workbench/app/Automations.tsx index a371727dbb..65f3102398 100644 --- a/products/maka/workbench/app/Automations.tsx +++ b/products/maka/workbench/app/Automations.tsx @@ -87,7 +87,9 @@ function AutomationEditor(props: { ? 'new' : effect?.kind === 'executor_session' ? 'followup' - : (effect?.delivery ?? 'steering'), + : effect?.kind === 'session' + ? (effect.delivery ?? 'steering') + : 'steering', ); const title = signal(task?.title ?? ''); const text = signal(task?.intent.body ?? '');