Release 0.2.0 #4
@@ -16,7 +16,7 @@ The design lives in **`blueprint/project-family.md`**. Read it before any archit
|
|||||||
Load-bearing decisions from that document:
|
Load-bearing decisions from that document:
|
||||||
|
|
||||||
- **Not upstreamable.** Nothing here needs to preserve Skald's schema or be portable back to it.
|
- **Not upstreamable.** Nothing here needs to preserve Skald's schema or be portable back to it.
|
||||||
- **Greenfield.** No users in production ⇒ **no migrations, no backwards compatibility**. Tables get restructured, renamed and moved freely; the schema collapses into a single clean baseline v1.
|
- **~~Greenfield~~ — no longer true. The instance is in production.** There are live users with data we cannot recreate, so the greenfield licence (restructure, rename, wipe, recreate) has expired: **every schema change now needs a versioning mechanism**, and "drop the box and re-run setup" stopped being an acceptable answer. Until that mechanism exists, the only safe change is an additive one through `db::ensure_column` (see the DB section); anything that renames, drops, retypes or moves a column or table is **blocked** on building schema versioning first, not something to do carefully by hand. A user's `{userid}.db` is SQLCipher-encrypted and readable **only while they are logged in**, so a migration cannot be a boot-time sweep over every file — it has to run per user, at unlock, and be idempotent. Design for that when the time comes.
|
||||||
- **Dual memory**: a private per-user pool plus a shared pool. A user's private space is encrypted so that nobody else — the admin included — can read it *through normal use of the system*. Never claim "mathematically impossible": the honest promise is transparency plus verifiability (§3).
|
- **Dual memory**: a private per-user pool plus a shared pool. A user's private space is encrypted so that nobody else — the admin included — can read it *through normal use of the system*. Never claim "mathematically impossible": the honest promise is transparency plus verifiability (§3).
|
||||||
- **Threat model** (§2): the adversary is the **tempted admin**, who owns the box but does not recompile the binary or dump RAM. Do not design against a forensic attacker.
|
- **Threat model** (§2): the adversary is the **tempted admin**, who owns the box but does not recompile the binary or dump RAM. Do not design against a forensic attacker.
|
||||||
- **Roles are data, not enums** (§0.1): a `roles` table binds permission-group, run-context and data-handling attributes. "Children" is a seeded preset row, never a hardcoded type.
|
- **Roles are data, not enums** (§0.1): a `roles` table binds permission-group, run-context and data-handling attributes. "Children" is a seeded preset row, never a hardcoded type.
|
||||||
@@ -132,7 +132,7 @@ The schema is split into two buckets (§5.1), and the split is the point:
|
|||||||
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`, `supervision`, `system_agent_coverage`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key.
|
- **`create_registry_tables`** — instance-wide, readable without any user key: `users`, `roles`, `llm_providers`, `llm_models`, `transcribe_models`, `tts_models`, `image_generate_models`, `plugins`, `plugin_access` + `plugin_user_configs`, `approval_rules`, `tool_permission_groups`, `config`, `known_tools`, `llm_requests`, `mcp_catalog`, `mcp_global_servers` + `mcp_global_access`, `oauth_providers`, `role_capabilities`, `shared_folders` + `shared_folder_members`, `projects` + `project_members`, `supervision`, `system_agent_coverage`. The MCP tables back the Connectors model (§7/§14/§15 — see its own section); `oauth_providers` (accessor `db/oauth_providers.rs`) holds one row per identity provider (Google…) — endpoints + `client_id`/`client_secret` + `redirect_uri`, admin-owned household secrets (§4/§15b), never a per-user token. The last two pairs are junction-backed membership: `shared_folder_members` (accessor `db/shared_folders.rs`) for the on-disk shared folders (§6), `project_members` (accessor `db/project_members.rs`) for projects (see the Projects section) — both let a member be read-only (`can_write`) and both drive the container mount topology + the fs routing. Their FKs are registry→registry (same file), which is allowed — unlike an owner→registry key.
|
||||||
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `system_agent_runs`, `system_agent_state`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`), `reports`. `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.)
|
- **`create_owner_tables`** — one owner's content, **identical schema in every file that has it**: `chat_sessions`, `chat_sessions_stack`, `chat_history`, `chat_llm_tools`, `chat_summaries`, `session_scratchpad`, `session_mcp_grants`, `stack_mcp_grants`, `scheduled_jobs`, `job_runs`, `system_agent_runs`, `system_agent_state`, `mcp_user_servers`, `mcp_events`, `sources`, `secrets`, `llm_request_payloads`, `memory_docs` (+ FTS5 `memory_docs_fts`), `reports`. `mcp_user_servers` (a user's activated per-user connectors) carries `catalog_name` as a **bare `TEXT` snapshot** of `mcp_catalog.name`, never a FK — an owner→registry key would fail every INSERT; for an OAuth connector it also snapshots `oauth_provider` + `deliver_json`, and its `api_key` column holds the refresh token (in the SQLCipher-encrypted file, so no column crypto). Because `memory_docs` is an owner table, one definition backs **private** memory in each `{userid}.db` and **shared** memory in `system.db` (the household owner) — see the memory namespace note below. (`projects`/`project_tickets` were owner tables in the single-user past: projects are shareable now, so `projects` + `project_members` are registry tables and `project_tickets` is gone.)
|
||||||
|
|
||||||
Schema is greenfield (no migrations, §0), but a purely **additive** column lands on an existing DB in place: `db::ensure_column` runs `ALTER TABLE … ADD COLUMN` and swallows the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already has the column. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers` so a dev box need not be wiped for an additive change (a full recreate is still valid).
|
**The schema is no longer greenfield** (see the production note at the top): a full recreate is not an option anymore. `db::ensure_column` — `ALTER TABLE … ADD COLUMN` swallowing the "duplicate column" error, a no-op on a fresh DB where the `CREATE TABLE` already carries it — is therefore not a convenience for dev boxes anymore but the **only** change shape that is currently safe, and additive-with-a-default is the shape to design towards. Used for the OAuth columns on `mcp_catalog` / `mcp_user_servers`. Anything destructive waits for real versioning.
|
||||||
|
|
||||||
**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. One key crossed and was fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model).
|
**No foreign key in the owner bucket may point at a registry table.** SQLite cannot enforce a key across files, not even through `ATTACH`, and sqlx turns on `PRAGMA foreign_keys`: the `CREATE TABLE` succeeds and every `INSERT` fails. `db::tests::owner_tables_stand_alone_with_foreign_keys_on` enforces this by running the owner schema against a database holding nothing else, then inserting a row into each table. One key crossed and was fixed: `chat_history.model_db_id` (dropped — write-only, and `llm_requests.model_name` already records the model).
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Rules of thumb:
|
|||||||
|
|
||||||
- **`mode=async`** — **the default for anything non-trivial.** It launches without blocking you, so you keep talking to the user while it runs. When it finishes, the system injects the result as a synthetic `task_completed` tool call — react to it and relay the outcome. After launching, tell the user it is running, then **do not poll** — the result arrives on its own.
|
- **`mode=async`** — **the default for anything non-trivial.** It launches without blocking you, so you keep talking to the user while it runs. When it finishes, the system injects the result as a synthetic `task_completed` tool call — react to it and relay the outcome. After launching, tell the user it is running, then **do not poll** — the result arrives on its own.
|
||||||
- **`mode=sync`** — run now and block for the answer. Only for **short** sub-tasks whose result you need immediately to finish composing your current reply.
|
- **`mode=sync`** — run now and block for the answer. Only for **short** sub-tasks whose result you need immediately to finish composing your current reply.
|
||||||
- **`mode=cron`** — schedule a recurring or one-shot task (7-field cron expression, `Europe/London`). The result arrives as a notification.
|
- **`mode=cron`** — schedule a recurring or one-shot task (7-field cron expression; the tool description names the timezone it is evaluated in). The result arrives as a notification.
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,15 @@ pub struct LlmConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Controls date/time injection in the dynamic tail of each LLM request.
|
/// Controls date/time injection in the dynamic tail of each LLM request.
|
||||||
|
///
|
||||||
|
/// The injected time is **always** truncated to the hour, and the block says so:
|
||||||
|
/// see [`crate::loop_adapters::system`]. There is deliberately no rounding knob —
|
||||||
|
/// the granularity is part of what the model is told, not an instance setting.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct DatetimeConfig {
|
pub struct DatetimeConfig {
|
||||||
/// Inject the current date/time into the LLM context. Default: true.
|
/// Inject the current date/time into the LLM context. Default: true.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// When set, round the injected time down to the nearest N-minute boundary.
|
|
||||||
pub round_minutes: Option<u32>,
|
|
||||||
/// IANA timezone name to use when formatting the injected timestamp.
|
/// IANA timezone name to use when formatting the injected timestamp.
|
||||||
/// Populated at startup from the global `timezone` config field.
|
/// Populated at startup from the global `timezone` config field.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
@@ -54,7 +56,7 @@ pub struct DatetimeConfig {
|
|||||||
|
|
||||||
impl Default for DatetimeConfig {
|
impl Default for DatetimeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { enabled: true, round_minutes: None, timezone: None }
|
Self { enabled: true, timezone: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ impl TaskManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The zone cron expressions are evaluated in — the configured `timezone`,
|
||||||
|
/// else the system's. Exists so the `execute_task` tool description can name
|
||||||
|
/// it instead of hardcoding one: a model told the wrong zone writes a
|
||||||
|
/// correct-looking expression that fires at the wrong hour.
|
||||||
|
pub fn timezone_name(&self) -> String {
|
||||||
|
self.tz
|
||||||
|
.map(|tz| tz.name().to_string())
|
||||||
|
.or_else(|| iana_time_zone::get_timezone().ok())
|
||||||
|
.unwrap_or_else(|| "the server's local timezone".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Called once after ChatSessionManager is built, breaking the circular dep.
|
/// Called once after ChatSessionManager is built, breaking the circular dep.
|
||||||
pub fn set_session(&self, session: Arc<ChatSessionManager>) {
|
pub fn set_session(&self, session: Arc<ChatSessionManager>) {
|
||||||
let _ = self.session.set(session);
|
let _ = self.session.set(session);
|
||||||
|
|||||||
@@ -143,6 +143,18 @@ fn os_description() -> &'static str {
|
|||||||
OS.get_or_init(|| os_info::get().to_string())
|
OS.get_or_init(|| os_info::get().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats an instant to hour precision: `Sunday 2026-08-02 17:00 +02:00`.
|
||||||
|
///
|
||||||
|
/// Minutes and seconds are dropped by the format string itself, so the
|
||||||
|
/// truncation always happens in the zone being displayed. The weekday is part
|
||||||
|
/// of the format on purpose — see [`AgentSystemContext::datetime_block`].
|
||||||
|
fn render_hour<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> String
|
||||||
|
where
|
||||||
|
Tz::Offset: std::fmt::Display,
|
||||||
|
{
|
||||||
|
dt.format("%A %Y-%m-%d %H:00 %:z").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// System IANA timezone name, computed once.
|
/// System IANA timezone name, computed once.
|
||||||
fn system_timezone() -> Option<&'static str> {
|
fn system_timezone() -> Option<&'static str> {
|
||||||
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||||
@@ -169,23 +181,25 @@ impl AgentSystemContext {
|
|||||||
|
|
||||||
/// The current date/time + OS + cwd block (`None` when disabled).
|
/// The current date/time + OS + cwd block (`None` when disabled).
|
||||||
///
|
///
|
||||||
/// Rounding exists for the prompt cache: a timestamp that changes every
|
/// The time is **truncated to the hour**, always, and the block says so in
|
||||||
/// second would invalidate any cached suffix, so the instance can quantize
|
/// words. Two reasons, neither of which is the prompt cache — this block is
|
||||||
/// it (this block is in the dynamic tail, after the cached prefix, but the
|
/// the last system message, after the whole conversation, so the cached
|
||||||
/// rounding still helps providers that cache further).
|
/// prefix is identical from one turn to the next whatever the timestamp says:
|
||||||
|
///
|
||||||
|
/// 1. **Honesty.** A second-precision timestamp reads as exact to the model
|
||||||
|
/// long after it stopped being true (it is built once per request, and a
|
||||||
|
/// turn can run for minutes). An hour-precision one that announces itself
|
||||||
|
/// as such lets the model know what it does *not* know — which matters
|
||||||
|
/// when it is about to write a cron expression from "in ten minutes".
|
||||||
|
/// 2. It keeps the block cache-safe if it ever moves into the prefix.
|
||||||
|
///
|
||||||
|
/// The weekday is spelled out: "next Tuesday" is a far more common ask than
|
||||||
|
/// the minute, and deriving it from a date is exactly the arithmetic models
|
||||||
|
/// get wrong.
|
||||||
fn datetime_block(&self) -> Option<String> {
|
fn datetime_block(&self) -> Option<String> {
|
||||||
if !self.datetime.enabled {
|
if !self.datetime.enabled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let secs = chrono::Utc::now().timestamp();
|
|
||||||
let secs = match self.datetime.round_minutes {
|
|
||||||
Some(m) if m > 0 => {
|
|
||||||
let bucket = (m as i64) * 60;
|
|
||||||
(secs / bucket) * bucket
|
|
||||||
}
|
|
||||||
_ => secs,
|
|
||||||
};
|
|
||||||
|
|
||||||
let tz = self
|
let tz = self
|
||||||
.datetime
|
.datetime
|
||||||
.timezone
|
.timezone
|
||||||
@@ -193,30 +207,15 @@ impl AgentSystemContext {
|
|||||||
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
|
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
|
||||||
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
|
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
|
||||||
|
|
||||||
|
// Truncate in the *displayed* zone, not on the UTC epoch: a zone at a
|
||||||
|
// 30- or 45-minute offset (Asia/Kolkata, Asia/Kathmandu) would otherwise
|
||||||
|
// render as `17:30`, which is not an hour boundary and reads as precise.
|
||||||
let (formatted, tz_name) = match tz {
|
let (formatted, tz_name) = match tz {
|
||||||
Some(tz) => {
|
Some(tz) => (
|
||||||
use chrono::TimeZone as _;
|
render_hour(chrono::Utc::now().with_timezone(&tz)),
|
||||||
let f = tz
|
Some(tz.name().to_string()),
|
||||||
.timestamp_opt(secs, 0)
|
),
|
||||||
.single()
|
None => (render_hour(chrono::Local::now()), None),
|
||||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
|
|
||||||
});
|
|
||||||
(f, Some(tz.name().to_string()))
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let f = chrono::DateTime::from_timestamp(secs, 0)
|
|
||||||
.map(|utc| {
|
|
||||||
utc.with_timezone(&chrono::Local)
|
|
||||||
.format("%Y-%m-%dT%H:%M:%S%:z")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
|
|
||||||
});
|
|
||||||
(f, None)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let date_line = match tz_name {
|
let date_line = match tz_name {
|
||||||
Some(name) => format!("Current date and time: {formatted} ({name})"),
|
Some(name) => format!("Current date and time: {formatted} ({name})"),
|
||||||
@@ -227,7 +226,11 @@ impl AgentSystemContext {
|
|||||||
let cwd = "~";
|
let cwd = "~";
|
||||||
|
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
|
"{date_line}\n\
|
||||||
|
The time above is truncated to the hour — you do not know the current minute. \
|
||||||
|
If you need it exactly (for instance to schedule something within the hour), \
|
||||||
|
run `date` with execute_cmd first.\n\
|
||||||
|
Operating system: {}\nWorking directory: {cwd}\n\
|
||||||
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
|
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
|
||||||
os_description()
|
os_description()
|
||||||
))
|
))
|
||||||
@@ -505,6 +508,23 @@ mod tests {
|
|||||||
assert_eq!(out, input);
|
assert_eq!(out, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hour_render_names_the_day_and_drops_the_minutes() {
|
||||||
|
let instant = chrono::DateTime::parse_from_rfc3339("2026-08-02T15:54:31Z").unwrap();
|
||||||
|
let rome = instant.with_timezone(&chrono_tz::Europe::Rome);
|
||||||
|
assert_eq!(render_hour(rome), "Sunday 2026-08-02 17:00 +02:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hour_render_truncates_in_the_displayed_zone() {
|
||||||
|
// Asia/Kolkata is +05:30: truncating the UTC epoch instead would render
|
||||||
|
// 20:30 — an hour off AND not on an hour boundary, so it would read as
|
||||||
|
// a precise time. Truncation must happen after the zone conversion.
|
||||||
|
let instant = chrono::DateTime::parse_from_rfc3339("2026-08-02T15:54:31Z").unwrap();
|
||||||
|
let kolkata = instant.with_timezone(&chrono_tz::Asia::Kolkata);
|
||||||
|
assert_eq!(render_hour(kolkata), "Sunday 2026-08-02 21:00 +05:30");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_folders_table_renders_access_and_description() {
|
fn shared_folders_table_renders_access_and_description() {
|
||||||
use crate::db::shared_folders::SharedFolderAccess;
|
use crate::db::shared_folders::SharedFolderAccess;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ pub fn mcp() -> Arc<dyn McpProvider> {
|
|||||||
/// The datetime block is disabled: it embeds `now()`, which no snapshot can
|
/// The datetime block is disabled: it embeds `now()`, which no snapshot can
|
||||||
/// pin down.
|
/// pin down.
|
||||||
pub fn datetime() -> DatetimeConfig {
|
pub fn datetime() -> DatetimeConfig {
|
||||||
DatetimeConfig { enabled: false, round_minutes: None, timezone: None }
|
DatetimeConfig { enabled: false, timezone: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The base tool definitions the projection is handed.
|
/// The base tool definitions the projection is handed.
|
||||||
|
|||||||
@@ -18,18 +18,23 @@ use crate::tools::{SimpleExecution, Tool, ToolContext, ToolDescriptionLength, To
|
|||||||
pub struct ExecuteTask(pub Arc<TaskManager>);
|
pub struct ExecuteTask(pub Arc<TaskManager>);
|
||||||
|
|
||||||
impl ExecuteTask {
|
impl ExecuteTask {
|
||||||
fn description_text() -> &'static str {
|
/// `tz` is the zone the scheduler actually evaluates expressions in
|
||||||
|
/// (`TaskManager::timezone_name`), never a literal: the description is what
|
||||||
|
/// the model reasons from, so a wrong zone here is an hours-off cron job.
|
||||||
|
fn description_text(tz: &str) -> String {
|
||||||
|
format!(
|
||||||
"Create and run a task. Three modes:\n\
|
"Create and run a task. Three modes:\n\
|
||||||
• mode=cron — scheduled by a 7-field cron expression (sec min hour dom month dow year, \
|
• mode=cron — scheduled by a 7-field cron expression (sec min hour dom month dow year, \
|
||||||
Europe/London timezone). Returns task_id and next scheduled run. Recurring unless the \
|
{tz} timezone). Returns task_id and next scheduled run. Recurring unless the \
|
||||||
expression can only fire once.\n\
|
expression can only fire once.\n\
|
||||||
• mode=sync — run immediately, block until the agent finishes, and return the result inline. \
|
• mode=sync — run immediately, block until the agent finishes, and return the result inline. \
|
||||||
Best for short tasks (a few seconds to a few minutes).\n\
|
Best for short tasks (a few seconds to a few minutes).\n\
|
||||||
• mode=async — start the task in the background and return the task_id immediately. \
|
• mode=async — start the task in the background and return the task_id immediately. \
|
||||||
When the task completes its result will be delivered back to this chat automatically."
|
When the task completes its result will be delivered back to this chat automatically."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema() -> Value {
|
fn schema(tz: &str) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["mode", "title", "prompt", "agent_id"],
|
"required": ["mode", "title", "prompt", "agent_id"],
|
||||||
@@ -41,7 +46,7 @@ impl ExecuteTask {
|
|||||||
},
|
},
|
||||||
"title": { "type": "string", "description": "Short name for this task" },
|
"title": { "type": "string", "description": "Short name for this task" },
|
||||||
"description": { "type": "string", "description": "What this task does" },
|
"description": { "type": "string", "description": "What this task does" },
|
||||||
"cron": { "type": "string", "description": "7-field cron expression — required when mode=cron (times in Europe/London). E.g. '0 0 9 * * * *' = every day at 09:00" },
|
"cron": { "type": "string", "description": format!("7-field cron expression — required when mode=cron (times in {tz}). E.g. '0 0 9 * * * *' = every day at 09:00") },
|
||||||
"prompt": { "type": "string", "description": "Prompt sent to the agent at each run" },
|
"prompt": { "type": "string", "description": "Prompt sent to the agent at each run" },
|
||||||
"agent_id": { "type": "string", "description": "Task agent to run (required; e.g. software-engineer, researcher, generalist). Must be a `task` agent — chat/system agents are rejected." }
|
"agent_id": { "type": "string", "description": "Task agent to run (required; e.g. software-engineer, researcher, generalist). Must be a `task` agent — chat/system agents are rejected." }
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,7 @@ pub fn build_execute_task_interface_tool(
|
|||||||
) -> crate::session::handler::InterfaceTool {
|
) -> crate::session::handler::InterfaceTool {
|
||||||
use crate::session::handler::{InterfaceTool, ToolFuture};
|
use crate::session::handler::{InterfaceTool, ToolFuture};
|
||||||
|
|
||||||
|
let tz = task_mgr.timezone_name();
|
||||||
let tool = Arc::new(ExecuteTask(task_mgr));
|
let tool = Arc::new(ExecuteTask(task_mgr));
|
||||||
|
|
||||||
InterfaceTool {
|
InterfaceTool {
|
||||||
@@ -114,8 +120,8 @@ pub fn build_execute_task_interface_tool(
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "execute_task",
|
"name": "execute_task",
|
||||||
"description": ExecuteTask::description_text(),
|
"description": ExecuteTask::description_text(&tz),
|
||||||
"parameters": ExecuteTask::schema(),
|
"parameters": ExecuteTask::schema(&tz),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
handler: Arc::new(move |args: Value| -> ToolFuture {
|
handler: Arc::new(move |args: Value| -> ToolFuture {
|
||||||
|
|||||||
+10
-5
@@ -60,9 +60,12 @@ llm:
|
|||||||
# execute_subtask) in a single response. Bounds fan-out to avoid provider
|
# execute_subtask) in a single response. Bounds fan-out to avoid provider
|
||||||
# rate-limit storms. Omit for the default (4); set to 1 to force sequential.
|
# rate-limit storms. Omit for the default (4); set to 1 to force sequential.
|
||||||
max_parallel_subagents: 4
|
max_parallel_subagents: 4
|
||||||
|
# Injects "Current date and time: Sunday 2026-08-02 17:00 +02:00 (Europe/Rome)"
|
||||||
|
# as the last system message of each request. The time is always truncated to
|
||||||
|
# the hour and the block tells the model so, pointing it at `date` when it
|
||||||
|
# needs the exact minute — there is no rounding setting.
|
||||||
datetime:
|
datetime:
|
||||||
enabled: true
|
enabled: true
|
||||||
round_minutes: 60 # Help with KV cache (instead of 10:54, it will pass 10:50 to the LLM)
|
|
||||||
|
|
||||||
# ── Tool result size limit ──────────────────────────────────────────────────
|
# ── Tool result size limit ──────────────────────────────────────────────────
|
||||||
# When set, tool results from *previous* turns that exceed this character
|
# When set, tool results from *previous* turns that exceed this character
|
||||||
@@ -109,11 +112,13 @@ llm:
|
|||||||
# ───────────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── Date/time injection ─────────────────────────────────────────────────────
|
# ── Date/time injection ─────────────────────────────────────────────────────
|
||||||
# Configured above as `datetime`. Rounding keeps the injected timestamp stable
|
# Configured above as `datetime`. The only setting is `enabled`.
|
||||||
# for up to N minutes, so the dynamic tail can be KV-cached across requests
|
# The injected time is always truncated to the hour: not for the prompt cache
|
||||||
# instead of changing every second.
|
# (the block is the LAST system message, so the cached prefix never changes
|
||||||
|
# whatever the timestamp says) but because a second-precision stamp reads as
|
||||||
|
# exact to the model long after it stopped being true. The block states the
|
||||||
|
# granularity and tells the agent to run `date` when it needs the minute.
|
||||||
# enabled: true # set to false to disable injection entirely
|
# enabled: true # set to false to disable injection entirely
|
||||||
# round_minutes: 10 # round down to nearest N minutes (e.g. 10:56 → 10:50)
|
|
||||||
# ───────────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── LLM request/response log ────────────────────────────────────────────────
|
# ── LLM request/response log ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user