Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

7 changes: 7 additions & 0 deletions crates/tinycomputer-bus/src/agent/types/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ pub struct StartTaskRequest {
/// records only.
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<TaskOutput>,
/// The site the task starts on, a plain host name. When set and the host
/// serves `SiteMemoryHost`, the module hands the task what finished tasks
/// on that site left (a saved plan for the same goal and facts, grounding
/// hints) and, once the task ends, keeps what it learned there. Absent,
/// the task neither reads nor writes site memory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
Comment on lines +80 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose site in the StartTask schemas

The new public site field is absent from both tinycomputer_engine::task::describe::start_task_input() and crates/tinycomputer-skills/schemas/start_task.schema.json. Consequently, callers that build tool inputs from Describe, and agents using the packaged skill schema, are never offered the only field that enables this feature and will continue sending requests that bypass site memory. Add the field to both advertised schemas and their schema-coverage tests.

AGENTS.md reference: AGENTS.md:L414-L415

Useful? React with 👍 / 👎.

}

/// What a finished task returns, beyond its raw records.
Expand Down
14 changes: 13 additions & 1 deletion crates/tinycomputer-bus/src/site_memory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ pub struct LoadSiteResponse {
/// What is kept; empty for a site with nothing.
#[serde(default)]
pub memory: SiteMemory,
/// Counts the times the site was forgotten (`Forget`). The module sends
/// back the value it loaded with its `Save`, and the host refuses a save
/// whose epoch is no longer current, so a task that was running when its
/// site was forgotten cannot write it back. A host that never forgets
/// under a running task may always answer `0`.
#[serde(default)]
pub epoch: u64,
}

/// `Save`: replaces one site's memory.
Expand All @@ -110,12 +117,17 @@ pub struct SaveSiteRequest {
pub site: String,
/// What to keep; empty removes the site.
pub memory: SiteMemory,
/// The `epoch` the site was loaded with; see [`LoadSiteResponse::epoch`].
#[serde(default)]
pub epoch: u64,
}

/// The reply to `Save`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SaveSiteResponse {
/// Whether the host holds the memory as sent.
/// Whether the host holds the memory as sent. `false` when the host
/// refused it: too large, learning switched off, or the site forgotten
/// since it was loaded (a stale `epoch`).
pub saved: bool,
}

Expand Down
23 changes: 23 additions & 0 deletions crates/tinycomputer-bus/src/site_memory/site_memory_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,26 @@ fn forgetting_without_a_site_means_every_site() {
let request: ForgetSiteRequest = serde_json::from_str("{}").unwrap();
assert_eq!(request.site, None);
}

#[test]
fn an_epoch_is_optional_on_the_wire() {
let load: LoadSiteResponse = serde_json::from_str(r#"{"memory":{}}"#).unwrap();
assert_eq!(load.epoch, 0);
let save: SaveSiteRequest =
serde_json::from_str(r#"{"site":"example.com","memory":{}}"#).unwrap();
assert_eq!(save.epoch, 0);
let save: SaveSiteRequest =
serde_json::from_str(r#"{"site":"example.com","memory":{},"epoch":3}"#).unwrap();
assert_eq!(save.epoch, 3);
}

#[test]
fn a_start_task_site_is_optional_and_left_out_when_absent() {
use crate::agent::StartTaskRequest;
let request: StartTaskRequest = serde_json::from_str("{}").unwrap();
assert_eq!(request.site, None);
let json = serde_json::to_value(&request).unwrap();
assert!(json.get("site").is_none());
let request: StartTaskRequest = serde_json::from_str(r#"{"site":"example.com"}"#).unwrap();
assert_eq!(request.site.as_deref(), Some("example.com"));
}
4 changes: 2 additions & 2 deletions crates/tinycomputer-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! release workflow bumps, which tracks the shipped artifact.

/// The wire contract version this crate defines.
pub const CONTRACT_VERSION: (u32, u32) = (2, 16);
pub const CONTRACT_VERSION: (u32, u32) = (2, 17);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the 2.17 contract bump in the normative spec

This bumps CONTRACT_VERSION to 2.17 but leaves docs/technical/specs/desktop-module-contract.md without the required 2.17 entry describing StartTask.site and the SiteMemory epoch fields, so the normative contract history no longer identifies what a 2.17 host may send or expect. Update that specification alongside the version bump.

AGENTS.md reference: AGENTS.md:L182-L185

Useful? React with 👍 / 👎.


/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module
/// reporting `module`.
Expand All @@ -22,7 +22,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (2, 16);
/// ```
/// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION};
/// assert!(is_compatible(CONTRACT_VERSION));
/// assert!(is_compatible((2, 16)));
/// assert!(is_compatible((2, 17)));
/// assert!(!is_compatible((2, 11)));
/// assert!(!is_compatible((1, 8)));
/// ```
Expand Down
6 changes: 4 additions & 2 deletions crates/tinycomputer-bus/src/version/version_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible};

#[test]
fn the_shipped_contract_version_is_pinned() {
assert_eq!(CONTRACT_VERSION, (2, 16));
assert_eq!(CONTRACT_VERSION, (2, 17));
}

#[test]
Expand All @@ -16,7 +16,7 @@ fn the_contract_binds_to_itself() {

#[test]
fn a_newer_minor_on_the_module_side_binds() {
assert!(is_compatible((2, 16)));
assert!(is_compatible((2, 17)));
assert!(is_compatible((2, 97)));
// 2.12 adds replayable GlobeRead and joined terminal GlobeShutdown.
// A 2.11 module does not implement those members.
Expand All @@ -28,6 +28,8 @@ fn a_newer_minor_on_the_module_side_binds() {
// 2.14 adds BrowserClaimProfile and BrowserReleaseProfile.
// 2.15 adds browser::tool: the recorded `browser` tool schema and ToolArgs.
// 2.16 adds desktop_tool: the eight desktop tool declarations and request builders.
// 2.17 adds `StartTask.site` and the `epoch` on `SiteMemoryHost` Load/Save.
assert!(!is_compatible((2, 16)));
assert!(!is_compatible((2, 15)));
assert!(!is_compatible((2, 14)));
assert!(!is_compatible((2, 13)));
Expand Down
1 change: 1 addition & 0 deletions crates/tinycomputer-examples/src/bin/task_live/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ async fn main() -> Result<(), LabError> {
trace: true,
output,
memory,
site: None,
})
.await?;
let limit = Duration::from_secs(
Expand Down
2 changes: 2 additions & 0 deletions crates/tinycomputer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ getrandom = "0.4"
# takes one dependency rather than two, and so `tinycomputer::SnapshotRequest`
# and `tinycomputer_bus::SnapshotRequest` are the same type.
tinycomputer-bus = { workspace = true }
# The per-site memory rules (reuse, fact filtering, expiry) a task is run under.
tinycomputer-core = { workspace = true }
# The agent-desktop adapter. `Desktop`, `Error`, and `Result` are re-exported.
tinycomputer-desktop = { workspace = true }
# The agent runtime behind the agentic and task members: Jev loops, goals,
Expand Down
25 changes: 20 additions & 5 deletions crates/tinycomputer/src/tinybus_module/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ use tinycomputer_bus::agent::{
};

use super::runner::jev_not_configured;
use super::site_memory::SiteLink;
use crate::Desktop;
use tinycomputer_browser::{
Action, Browser, DownloadWaitRequest, EvaluateRequest, NavigateRequest, OutputRequest,
Expand Down Expand Up @@ -75,6 +76,8 @@ pub(crate) struct DesktopService {
/// `BrowserOpenSession` wherever the caller left them unset.
browser_defaults: crate::tinybus_module::config::BrowserDefaults,
accessibility: Arc<accessibility::Access>,
/// The host's per-site memory, when the module was set up on a bus.
site_link: Option<Arc<SiteLink>>,
}

#[tinybus::interface(name = "ai.tinyhumans.tinycomputer.Desktop")]
Expand Down Expand Up @@ -149,15 +152,17 @@ impl DesktopService {
&self,
request: StartTaskRequest,
) -> TinyBusResult<AgentResponse<TaskView>> {
self.on_tasks(move |tasks| tasks.start(&request)).await
self.start_remembering(request).await
}

/// Waits until a task needs something, finishes, or the timeout passes.
async fn await_task(
&self,
request: AwaitTaskRequest,
) -> TinyBusResult<AgentResponse<TaskView>> {
Ok(self.tasks.await_task(request).await)
let reply = self.tasks.await_task(request).await;
self.learn_from(&reply).await;
Ok(reply)
}

/// Answers what a paused task asked for, and resumes it.
Expand All @@ -166,13 +171,23 @@ impl DesktopService {
&self,
request: ContinueTaskRequest,
) -> TinyBusResult<AgentResponse<TaskView>> {
self.on_tasks(move |tasks| tasks.continue_task(request))
.await
if let Some(link) = &self.site_link {
link.note_inputs(&request.id, &request.inputs);
}
let reply = self
.on_tasks(move |tasks| tasks.continue_task(request))
.await?;
self.learn_from(&reply).await;
Ok(reply)
}

/// Stops a task.
async fn cancel_task(&self, request: TaskRef) -> TinyBusResult<AgentResponse<TaskView>> {
self.on_tasks(move |tasks| tasks.cancel(&request.id)).await
let reply = self
.on_tasks(move |tasks| tasks.cancel(&request.id))
.await?;
self.learn_from(&reply).await;
Ok(reply)
}

/// Everything a task did.
Expand Down
57 changes: 56 additions & 1 deletion crates/tinycomputer/src/tinybus_module/dispatch/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ use std::sync::Arc;

use tinybus::{Error as TinyBusError, Result as TinyBusResult};
use tinycomputer_browser::{AgentBrowser, Browser};
use tinycomputer_bus::agent::{SurfaceAvailability, SurfaceKind};
use tinycomputer_bus::agent::{
AgentResponse, StartTaskRequest, SurfaceAvailability, SurfaceKind, TaskView,
};
use tinycomputer_bus::{DesktopResponse, JevConfig};
use tinycomputer_engine as agentic;

use super::DesktopService;
use crate::tinybus_module::config::{BrowserDefaults, cursor_config};
use crate::tinybus_module::runner::WorkspaceRunner;
use crate::tinybus_module::site_memory::{SiteLink, SiteMemoryPort, refused_flow};
use crate::{Desktop, Result};

impl DesktopService {
Expand Down Expand Up @@ -87,6 +90,7 @@ impl DesktopService {
browser,
browser_defaults,
accessibility: Arc::new(super::accessibility::Access::default()),
site_link: None,
})
}

Expand Down Expand Up @@ -200,3 +204,54 @@ impl DesktopService {
self
}
}

impl DesktopService {
/// Keeps what browser tasks learn about a site through `port`, the host's
/// `SiteMemoryHost`.
pub(crate) fn with_site_memory(mut self, port: Box<dyn SiteMemoryPort>) -> Self {
self.site_link = Some(Arc::new(SiteLink::new(port)));
self
}

/// Starts a task with what earlier tasks on its `site` left, and follows
/// it for what it may teach. A saved plan the engine refuses is
/// forgotten and the task started again to be planned afresh.
pub(super) async fn start_remembering(
&self,
mut request: StartTaskRequest,
) -> TinyBusResult<AgentResponse<TaskView>> {
let Some(link) = self.site_link.clone() else {
return self.on_tasks(move |tasks| tasks.start(&request)).await;
};
let mut prepared = link.prepare(&mut request).await;
let mut reply = self.start_once(request.clone()).await?;
if refused_flow(&reply)
&& let Some(reusing) = prepared.as_mut().filter(|reusing| reusing.reuses_plan())
{
link.refused(reusing).await;
request.flow = None;
reply = self.start_once(request).await?;
}
if let (Some(prepared), Some(view)) = (prepared, reply.data.as_ref()) {
link.follow(&view.id, prepared);
Comment on lines +235 to +236

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe completion without requiring another task call

When StartTask returns running and the background worker later finishes, this only records the task in followed; learning is triggered solely by later AwaitTask, ContinueTask, or CancelTask calls. A fire-and-forget caller, or one that later reads only TaskReport/ListTasks, therefore never invokes Save, despite the task having completed, and its learned plan and hints are silently lost. Attach completion observation when following the task, or make every final-status/report path perform the learning.

Useful? React with 👍 / 👎.

}
self.learn_from(&reply).await;
Ok(reply)
}

async fn start_once(
&self,
request: StartTaskRequest,
) -> TinyBusResult<AgentResponse<TaskView>> {
self.on_tasks(move |tasks| tasks.start(&request)).await
}

/// Learns from `reply`'s task once it has ended.
pub(super) async fn learn_from(&self, reply: &AgentResponse<TaskView>) {
let (Some(link), Some(view)) = (&self.site_link, reply.data.as_ref()) else {
return;
};
let tasks = self.tasks.clone();
link.learn(view, |id| tasks.report(id)).await;
}
}
6 changes: 5 additions & 1 deletion crates/tinycomputer/src/tinybus_module/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
mod config;
mod dispatch;
mod runner;
mod site_memory;

use serde_json::Value;
use tinybus::{Connection, Result as TinyBusResult};
Expand All @@ -38,6 +39,9 @@ pub(crate) use dispatch::DesktopService;
async fn setup(connection: Connection, config: Value) -> TinyBusResult<()> {
let service = DesktopService::from_config(&config)
.map_err(|error| tinybus::Error::failed(error.to_string()))?;
// The host's per-site memory; a host that does not serve it only costs
// tasks what earlier ones learned.
let service = service.with_site_memory(Box::new(site_memory::BusPort::new(connection.clone())));
#[cfg(test)]
let service = service.with_native_fixture();
service.sweep_outputs();
Expand Down Expand Up @@ -82,7 +86,7 @@ tinybus_module::module_export_optional_static! {
],
signals = [],
requires = [],
optional = [],
optional = ["ai.tinyhumans.tinycomputer.SiteMemoryHost"],
lazy = false,
}

Expand Down
Loading
Loading