Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
53b8a3f
feat(tools): add markdown output support for time tools
senamakel Sep 29, 2026
a8e7322
test(tools): add tests for time tool read-only and markdown support
senamakel Sep 29, 2026
dc30bb8
feat(goals): add update hook and unify goal tool response format
senamakel Sep 29, 2026
ca12b6f
test(goals): add tests for payload format, update hook, and permissio…
senamakel Sep 29, 2026
0283c22
chore(goals): export GoalUpdateHook and clean up imports
senamakel Sep 29, 2026
88b2f0f
chore(tool): remove unused import of `tinyagents_harness::error::Result`
senamakel Sep 29, 2026
e48380f
feat(tools,goals): port OpenHuman time-tool markdown and goal-tool pa…
senamakel Sep 29, 2026
e35c120
chore(deps): update vendor dependencies for tinyinference and tinytools
senamakel Sep 30, 2026
0a437a9
fix(harness): update summarization module for resilient model handling
senamakel Sep 30, 2026
15a35d1
feat(harness): add summarization module with model summarizer and res…
senamakel Sep 30, 2026
49db91e
chore: files changed crates/tinyagents-harness/src/lib.rs,crates/tiny…
senamakel Sep 30, 2026
b6339a3
fix(harness): correct model summarizer test to use resilient summarizer
senamakel Sep 30, 2026
ed9cc77
chore(deps): update tinytools vendor dependency
senamakel Sep 30, 2026
b3cbdfa
fix: keep goal results structured
senamakel Sep 30, 2026
28e42d4
Merge remote-tracking branch 'upstream/oh-dedupe-time-goals-tools' in…
senamakel Sep 30, 2026
ab5287d
fix: preserve complete summarization context
senamakel Sep 30, 2026
61b6114
Merge pull request #230 from tinyhumansai/oh-extract-leaves
senamakel Sep 30, 2026
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.

2 changes: 1 addition & 1 deletion crates/tinyagents-graph/src/goals/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ mod types;
pub use budget::{BudgetVerdict, GoalBudgetGuard, account_turn, accrues_usage, turn_tokens};
pub use continuation::{goal_gate_node, note_user_turn, run_continuation_tick};
pub use prompt::active_goal_context_block;
pub use tool::{GoalTool, GoalToolKind, goal_tools, register_goal_tools};
pub use tool::{GoalTool, GoalToolKind, GoalUpdateHook, goal_tools, register_goal_tools};
Comment thread
senamakel marked this conversation as resolved.
pub use types::{GoalProgress, ThreadGoal, ThreadGoalStatus, TurnOutcome};

#[cfg(test)]
Expand Down
88 changes: 85 additions & 3 deletions crates/tinyagents-graph/src/goals/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ mod tool_tests {
// Bare call (no context) errors.
let res = set.execute(json!({ "objective": "x" })).await.unwrap();
assert!(res.is_error);
assert!(res.output().contains("active thread"));
assert!(res.output().contains("active chat thread"));
// Context with no thread id also errors.
let set = GoalTool::new(GoalToolKind::Set, store());
let res = run(&set, None, json!({ "objective": "x" })).await;
Expand All @@ -340,10 +340,92 @@ mod tool_tests {
}

#[tokio::test]
async fn set_missing_objective_is_a_soft_error() {
async fn set_missing_objective_is_an_error_result() {
let set = GoalTool::new(GoalToolKind::Set, store());
let res = run(&set, Some("t"), json!({})).await;
assert!(res.output().contains("missing 'objective'"));
assert!(res.is_error);
assert!(res.output().contains("Missing 'objective'"));
}

#[tokio::test]
async fn tools_answer_with_goal_and_text_payload() {
let s = store();
let context = Some("thread-payload");

let get = GoalTool::new(GoalToolKind::Get, s.clone());
let res = run(&get, context, json!({})).await;
let empty = match &res.content[0] {
tinytools::ToolContent::Json { data } => data,
other => panic!("expected structured JSON content, got {other:?}"),
};
assert!(empty["goal"].is_null());
assert_eq!(empty["text"], "no goal set for this thread");

let set = GoalTool::new(GoalToolKind::Set, s.clone());
let res = run(
&set,
context,
json!({ "objective": "land the PR", "token_budget": 5000 }),
)
.await;
assert!(!res.is_error, "{res:?}");
let payload = match &res.content[0] {
tinytools::ToolContent::Json { data } => data,
other => panic!("expected structured JSON content, got {other:?}"),
};
assert_eq!(payload["goal"]["objective"], "land the PR");
assert_eq!(payload["goal"]["status"], "active");
assert_eq!(payload["goal"]["tokenBudget"], 5000);
assert_eq!(payload["goal"]["tokensUsed"], 0);
let text = payload["text"].as_str().unwrap();
assert!(text.starts_with("Goal set."), "{text}");
assert!(text.contains("objective: land the PR"), "{text}");
assert_eq!(res.markdown_formatted.as_deref(), Some(text));
}

#[tokio::test]
async fn update_hook_fires_on_writes_only() {
use std::sync::Mutex;

let s = store();
let seen: Arc<Mutex<Vec<String>>> = Arc::default();
let hook: super::super::tool::GoalUpdateHook = {
let seen = seen.clone();
Arc::new(move |goal| seen.lock().unwrap().push(goal.status.as_str().to_string()))
};
let tool = |kind| GoalTool::new(kind, s.clone()).with_update_hook(hook.clone());

run(&tool(GoalToolKind::Get), Some("t"), json!({})).await;
assert!(seen.lock().unwrap().is_empty());

run(
&tool(GoalToolKind::Set),
Some("t"),
json!({ "objective": "ship" }),
)
.await;
run(&tool(GoalToolKind::Get), Some("t"), json!({})).await;
run(&tool(GoalToolKind::Complete), Some("t"), json!({})).await;
assert_eq!(*seen.lock().unwrap(), vec!["active", "complete"]);

// A failed write (no goal to complete on another thread) does not fire.
run(&tool(GoalToolKind::Complete), Some("other"), json!({})).await;
assert_eq!(seen.lock().unwrap().len(), 2);
}

#[test]
fn permission_levels_follow_read_only() {
use tinytools::PermissionLevel;

for kind in GoalToolKind::ALL {
let tool = GoalTool::new(kind, store());
let expected = if kind == GoalToolKind::Get {
PermissionLevel::ReadOnly
} else {
PermissionLevel::Write
};
assert_eq!(tool.permission_level(), expected, "{}", kind.name());
}
}
}

Expand Down
147 changes: 108 additions & 39 deletions crates/tinyagents-graph/src/goals/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
//! that wants to expose them, but not part of the default model-facing set
//! returned by [`goal_tools`].
//!
//! Every control answers with the JSON `{ "goal": <ThreadGoal|null>, "text":
//! <rendered block> }`: `goal` is the structured camelCase goal a UI can draw
//! a banner from, `text` the model-readable rendering (also attached as the
//! markdown form). A host that must react to a change (publish an event, refresh
//! a chip) registers a hook with [`GoalTool::with_update_hook`].
//!
//! The target thread is resolved from
//! [`ToolExecutionContext::thread_id`](tinyagents_harness::tool::ToolExecutionContext),
//! the harness analogue of an ambient thread id: a tool never takes a
Expand All @@ -21,10 +27,15 @@ use serde_json::{Value, json};

use super::store;
use super::types::ThreadGoal;
use tinyagents_harness::error::Result;
use tinyagents_harness::store::Store;
use tinyagents_harness::tool::ToolRegistry;
use tinytools::{Tool, ToolPolicy, ToolResult, ToolRunContext, ToolSideEffects};
use tinytools::{PermissionLevel, Tool, ToolPolicy, ToolResult, ToolRunContext, ToolSideEffects};

/// Callback a host registers to observe a goal a tool just wrote.
///
/// Called after a successful `goal_set` / `goal_complete` / `goal_pause` /
/// `goal_resume` with the persisted goal (never for `goal_get` or `goal_clear`).
pub type GoalUpdateHook = Arc<dyn Fn(&ThreadGoal) + Send + Sync>;

/// Which thread-goal control a [`GoalTool`] implements.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
Expand Down Expand Up @@ -78,9 +89,11 @@ impl GoalToolKind {
usage. Returns 'no goal set' when the thread has none."
}
Self::Set => {
"Set (or replace) this thread's goal — the durable objective you should keep \
pursuing across turns until it's complete. Changing the objective resets \
usage counters. Optionally set a token_budget; when reached, work halts."
"Set (or replace) this thread's goal — the durable objective you should \
keep pursuing across turns until it's complete. Use at the start of a \
non-trivial request, or to refine the objective as it sharpens. Changing \
the objective resets usage counters. Optionally set a token_budget; when \
reached, the goal pauses with a progress summary."
}
Self::Complete => {
"Mark this thread's goal complete. Only call this when concrete evidence \
Expand Down Expand Up @@ -126,64 +139,100 @@ impl GoalToolKind {
pub struct GoalTool {
kind: GoalToolKind,
store: Arc<dyn Store>,
on_update: Option<GoalUpdateHook>,
}

impl GoalTool {
/// Creates one goal tool of `kind` backed by `store`.
pub fn new(kind: GoalToolKind, store: Arc<dyn Store>) -> Self {
Self { kind, store }
Self {
kind,
store,
on_update: None,
}
}

/// Registers a [`GoalUpdateHook`] invoked after each successful write.
#[must_use]
pub fn with_update_hook(mut self, hook: GoalUpdateHook) -> Self {
self.on_update = Some(hook);
self
}

/// The control kind this tool implements.
pub fn kind(&self) -> GoalToolKind {
self.kind
}

/// Dispatches the control against `thread_id`, returning the model-facing
/// content and a structured `raw` payload.
async fn dispatch(&self, thread_id: &str, args: &Value) -> Result<(String, Option<Value>)> {
/// Dispatches the control against `thread_id`, returning the goal (when
/// one exists after the call) and a model-facing note.
async fn dispatch(
&self,
thread_id: &str,
args: &Value,
) -> std::result::Result<(Option<ThreadGoal>, String), String> {
let stringify = |error: tinyagents_harness::error::TinyAgentsError| error.to_string();
match self.kind {
GoalToolKind::Get => match store::get(&self.store, thread_id).await? {
Some(goal) => Ok((render_goal(&goal), Some(serde_json::to_value(&goal)?))),
None => Ok(("no goal set for this thread".to_string(), None)),
GoalToolKind::Get => match store::get(&self.store, thread_id)
.await
.map_err(stringify)?
{
Some(goal) => Ok((Some(goal), String::new())),
None => Ok((None, "no goal set for this thread".to_string())),
},
GoalToolKind::Set => {
let Some(objective) = args.get("objective").and_then(Value::as_str) else {
return Ok(("error: missing 'objective' parameter".to_string(), None));
return Err("Missing 'objective' parameter".to_string());
};
let token_budget = args.get("token_budget").and_then(Value::as_u64);
let goal = store::set(&self.store, thread_id, objective, token_budget).await?;
Ok((
format!("Goal set.\n{}", render_goal(&goal)),
Some(serde_json::to_value(&goal)?),
))
let goal = store::set(&self.store, thread_id, objective, token_budget)
.await
.map_err(stringify)?;
Ok((Some(goal), "Goal set.".to_string()))
}
GoalToolKind::Complete => {
let goal = store::complete(&self.store, thread_id).await?;
Ok((
format!("Goal marked complete.\n{}", render_goal(&goal)),
Some(serde_json::to_value(&goal)?),
))
let goal = store::complete(&self.store, thread_id)
.await
.map_err(stringify)?;
Ok((Some(goal), "Goal marked complete.".to_string()))
}
GoalToolKind::Pause => {
let goal = store::pause(&self.store, thread_id).await?;
Ok((render_goal(&goal), Some(serde_json::to_value(&goal)?)))
let goal = store::pause(&self.store, thread_id)
.await
.map_err(stringify)?;
Ok((Some(goal), String::new()))
}
GoalToolKind::Resume => {
let goal = store::resume(&self.store, thread_id).await?;
Ok((render_goal(&goal), Some(serde_json::to_value(&goal)?)))
let goal = store::resume(&self.store, thread_id)
.await
.map_err(stringify)?;
Ok((Some(goal), String::new()))
}
GoalToolKind::Clear => {
let removed = store::clear(&self.store, thread_id).await?;
Ok((
format!("Goal cleared (removed={removed})."),
Some(json!({ "removed": removed })),
))
let removed = store::clear(&self.store, thread_id)
.await
.map_err(stringify)?;
Ok((None, format!("Goal cleared (removed={removed}).")))
}
}
}
}

/// Builds the `{ goal, text }` payload every goal control answers with.
/// `text` is `note` followed by the rendered goal block (or just `note` when
/// there is no goal).
fn goal_payload(goal: Option<&ThreadGoal>, note: &str) -> Value {
let text = match goal {
Some(goal) if note.is_empty() => render_goal(goal),
Some(goal) => format!("{note}\n{}", render_goal(goal)),
None => note.to_string(),
};
json!({
"goal": goal.map(|goal| serde_json::to_value(goal).unwrap_or(Value::Null)),
"text": text,
})
}

/// Renders a goal as a compact, model-readable block.
fn render_goal(goal: &ThreadGoal) -> String {
let budget = match goal.token_budget {
Expand Down Expand Up @@ -243,6 +292,14 @@ impl Tool for GoalTool {
true
}

fn permission_level(&self) -> PermissionLevel {
if self.kind.read_only() {
PermissionLevel::ReadOnly
} else {
PermissionLevel::Write
}
}

fn policy(&self) -> ToolPolicy {
ToolPolicy {
classified: true,
Expand All @@ -256,7 +313,7 @@ impl Tool for GoalTool {

async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
Ok(error_result(
"goal tools require an active thread (no thread_id in tool context)",
"thread goal tools require an active chat thread",
))
}

Expand All @@ -268,13 +325,25 @@ impl Tool for GoalTool {
) -> anyhow::Result<ToolResult> {
let Some(thread_id) = context.and_then(ToolRunContext::thread_id) else {
return Ok(error_result(
"goal tools require an active thread (no thread_id in tool context)",
"thread goal tools require an active chat thread",
));
};
let (content, raw) = self.dispatch(thread_id, &args).await?;
Ok(match raw {
Some(raw) => ToolResult::json(raw).with_markdown(content),
None => ToolResult::success(content),
})
tracing::debug!(
tool = self.kind.name(),
thread_id,
"[thread_goals] goal tool execute"
);
let (goal, note) = match self.dispatch(thread_id, &args).await {
Ok(outcome) => outcome,
Err(message) => return Ok(error_result(message)),
};
if let (Some(goal), Some(hook)) = (goal.as_ref(), self.on_update.as_ref())
&& !self.kind.read_only()
{
hook(goal);
}
let payload = goal_payload(goal.as_ref(), &note);
let text = payload["text"].as_str().unwrap_or_default().to_string();
Ok(ToolResult::json(payload).with_markdown(text))
}
}
1 change: 1 addition & 0 deletions crates/tinyagents-harness/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub mod stream;
pub mod structured;
pub mod summarization;
pub mod testkit;
pub mod title;
pub mod token_estimation;
pub mod tool;
#[cfg(feature = "builtin-tools")]
Expand Down
Loading
Loading