feat(ai): date_info tool for exact date arithmetic

Small local models get weekdays, "how long ago" and day counts between dates
wrong, confidently. Persona chats anchored on old SMS threads ask exactly
those questions. date_info answers them exactly, in the owner's display zone:
- no args: now (replaces get_current_datetime)
- date: its weekday and how long ago / until
- date + other_date: the gap in days, weeks and years/months/days, plus
  hours when both carry times
- date + add_days: the date N days after (or before)

Checked live on gemma4-26b-A4B and gemma4-31: each date question took one
date_info call and came back correct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-18 20:33:00 -04:00
1 parent 7215c1bdfe
commit edfa48d116
4 files changed
+378 -23

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
//! The `date_info` agent tool: date arithmetic the model shouldn't do in
//! its head.
//!
//! Small local models are reliably shaky at weekday lookups, "how long ago
//! was …" across year boundaries and day counts between two dates — and
//! they state the wrong answer confidently. Persona chats anchored on old
//! SMS threads ask exactly these questions, so the tool answers them
//! exactly. It also reports "now", replacing the old `get_current_datetime`.
//!
//! Everything is computed in the owner's display zone, so the dates match
//! the timestamps the model reads in thread transcripts and photo metadata.
use chrono::{DateTime, Datelike, Months, NaiveDate, NaiveDateTime, Utc};
use chrono_tz::Tz;
/// A date argument as the model gave it. A bare date stays a date (no
/// invented time of day); a date with a time keeps it.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Moment {
Day(NaiveDate),
At(NaiveDateTime),
}
impl Moment {
fn date(self) -> NaiveDate {
match self {
Moment::Day(d) => d,
Moment::At(dt) => dt.date(),
}
}
fn label(self) -> String {
match self {
Moment::Day(d) => format!("{} ({})", d.format("%Y-%m-%d"), d.format("%A")),
Moment::At(dt) => format!("{} ({})", dt.format("%Y-%m-%d %H:%M"), dt.format("%A")),
}
}
}
/// Parse `YYYY-MM-DD`, `YYYY-MM-DD HH:MM` (or with `T` / seconds), or unix
/// seconds (read in `zone`).
fn parse_moment(value: &serde_json::Value, zone: Tz) -> Option<Moment> {
if let Some(ts) = value.as_i64() {
let utc = DateTime::<Utc>::from_timestamp(ts, 0)?;
return Some(Moment::At(utc.with_timezone(&zone).naive_local()));
}
let raw = value.as_str()?.trim();
for fmt in [
"%Y-%m-%d %H:%M",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
] {
if let Ok(dt) = NaiveDateTime::parse_from_str(raw, fmt) {
return Some(Moment::At(dt));
}
}
if let Ok(ts) = raw.parse::<i64>() {
return parse_moment(&serde_json::json!(ts), zone);
}
NaiveDate::parse_from_str(raw, "%Y-%m-%d")
.ok()
.map(Moment::Day)
}
/// Whole years, months and days from `a` to `b` (`a <= b`), counted the
/// calendar way: Jan 31 + 1 month is Feb 28/29, as people count ages.
fn calendar_gap(a: NaiveDate, b: NaiveDate) -> (u32, u32, u32) {
let mut months = (b.year() - a.year()) * 12 + b.month() as i32 - a.month() as i32;
let mut anchor = a.checked_add_months(Months::new(months.max(0) as u32));
if anchor.is_none_or(|d| d > b) {
months -= 1;
anchor = a.checked_add_months(Months::new(months.max(0) as u32));
}
let months = months.max(0) as u32;
let days = anchor.map(|d| (b - d).num_days()).unwrap_or(0).max(0) as u32;
(months / 12, months % 12, days)
}
fn plural(n: impl Into<i64>, unit: &str) -> String {
let n = n.into();
if n == 1 {
format!("1 {unit}")
} else {
format!("{n} {unit}s")
}
}
/// "7 years, 3 months and 15 days" — zero parts dropped, "0 days" when the
/// dates are the same.
fn describe_gap(a: NaiveDate, b: NaiveDate) -> String {
let (y, m, d) = calendar_gap(a, b);
let mut parts = Vec::new();
if y > 0 {
parts.push(plural(y, "year"));
}
if m > 0 {
parts.push(plural(m, "month"));
}
if d > 0 || parts.is_empty() {
parts.push(plural(d, "day"));
}
match parts.len() {
1 => parts.remove(0),
_ => {
let last = parts.pop().unwrap_or_default();
format!("{} and {}", parts.join(", "), last)
}
}
}
/// The span between two dates: a headline phrase, plus a detail (exact
/// day count, weeks) the caller places after any "ago" / "in".
fn span(a: NaiveDate, b: NaiveDate) -> (String, String) {
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let days = (hi - lo).num_days();
let weeks = if days >= 14 {
format!("about {}", plural(days / 7, "week"))
} else {
String::new()
};
if days < 31 {
(plural(days, "day"), weeks)
} else {
let detail = if weeks.is_empty() {
plural(days, "day")
} else {
format!("{}, {weeks}", plural(days, "day"))
};
(describe_gap(lo, hi), detail)
}
}
/// `headline` with `(detail)` appended when there is one.
fn with_detail(headline: String, detail: &str) -> String {
if detail.is_empty() {
headline
} else {
format!("{headline} ({detail})")
}
}
/// Run the tool. `now` is injected so tests are deterministic.
pub fn date_info(args: &serde_json::Value, now: DateTime<Utc>, zone: Tz) -> String {
let now_local = now.with_timezone(&zone);
let today = now_local.date_naive();
let mut out = format!(
"Now: {} ({}), {}",
now_local.format("%Y-%m-%d %H:%M"),
now_local.format("%A"),
zone.name()
);
let first = args.get("date").filter(|v| !v.is_null());
let second = args.get("other_date").filter(|v| !v.is_null());
let add_days = args.get("add_days").and_then(|v| v.as_i64());
let Some(first) = first else {
return out;
};
let Some(a) = parse_moment(first, zone) else {
return format!(
"Error: couldn't read date {first} — use YYYY-MM-DD, YYYY-MM-DD HH:MM or unix seconds"
);
};
// Offset: "what date is N days after/before X".
if let Some(n) = add_days {
let Some(result) = a.date().checked_add_signed(chrono::Duration::days(n)) else {
return "Error: add_days is out of range".to_string();
};
let direction = if n >= 0 { "after" } else { "before" };
out.push_str(&format!(
"\n{} {direction} {} is {}.",
plural(n.abs(), "day"),
a.label(),
Moment::Day(result).label()
));
return out;
}
match second {
None => {
let d = a.date();
let relation = match d.cmp(&today) {
std::cmp::Ordering::Equal => "today".to_string(),
std::cmp::Ordering::Less => {
let (head, detail) = span(d, today);
with_detail(format!("{head} ago"), &detail)
}
std::cmp::Ordering::Greater => {
let (head, detail) = span(today, d);
with_detail(format!("in {head}"), &detail)
}
};
out.push_str(&format!("\n{} — {relation}.", a.label()));
}
Some(second) => {
let Some(b) = parse_moment(second, zone) else {
return format!(
"Error: couldn't read other_date {second} — use YYYY-MM-DD, YYYY-MM-DD HH:MM or unix seconds"
);
};
let (from, to) = if a.date() <= b.date() { (a, b) } else { (b, a) };
let mut gap = if from.date() == to.date() {
"the same day".to_string()
} else {
let (head, detail) = span(from.date(), to.date());
with_detail(head, &detail)
};
// Both with times and close together: the hours are what matter.
if let (Moment::At(x), Moment::At(y)) = (from, to) {
let minutes = (y - x).num_minutes().abs();
if minutes < 48 * 60 {
gap.push_str(&format!(" ({}h {}m apart)", minutes / 60, minutes % 60));
}
}
out.push_str(&format!(
"\nFrom {} to {}: {gap}.",
from.label(),
to.label()
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use serde_json::json;
const NY: Tz = chrono_tz::America::New_York;
fn at(zone: Tz, y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
zone.with_ymd_and_hms(y, m, d, h, min, 0)
.single()
.expect("unambiguous local time")
.with_timezone(&Utc)
}
fn now() -> DateTime<Utc> {
// Sunday 2026-10-18 20:15 in New York.
at(NY, 2026, 10, 18, 20, 15)
}
#[test]
fn no_date_reports_now_in_the_display_zone() {
assert_eq!(
date_info(&json!({}), now(), NY),
"Now: 2026-10-18 20:15 (Sunday), America/New_York"
);
}
#[test]
fn a_past_date_gets_its_weekday_and_how_long_ago() {
let out = date_info(&json!({"date": "2019-07-03"}), now(), NY);
assert!(
out.ends_with(
"2019-07-03 (Wednesday) — 7 years, 3 months and 15 days ago (2664 days, about 380 weeks)."
),
"{out}"
);
}
#[test]
fn near_dates_read_as_days() {
let out = date_info(&json!({"date": "2026-10-15"}), now(), NY);
assert!(
out.ends_with("2026-10-15 (Thursday) — 3 days ago."),
"{out}"
);
let out = date_info(&json!({"date": "2026-10-18"}), now(), NY);
assert!(out.ends_with("— today."), "{out}");
let out = date_info(&json!({"date": "2026-11-01"}), now(), NY);
assert!(out.ends_with("— in 14 days (about 2 weeks)."), "{out}");
}
#[test]
fn today_follows_the_display_zone_not_utc() {
// 02:30 UTC on the 19th is still the evening of the 18th in New York.
let late = at(NY, 2026, 10, 18, 22, 30);
let out = date_info(&json!({"date": "2026-10-18"}), late, NY);
assert!(out.ends_with("— today."), "{out}");
}
#[test]
fn unix_seconds_read_in_the_display_zone() {
// 2019-07-04 02:47 UTC = 2019-07-03 22:47 in New York.
let out = date_info(&json!({"date": 1562208420}), now(), NY);
assert!(out.contains("2019-07-03 22:47 (Wednesday)"), "{out}");
}
#[test]
fn two_dates_give_the_gap_in_either_order() {
let a = date_info(
&json!({"date": "2019-07-05", "other_date": "2019-07-03"}),
now(),
NY,
);
assert!(
a.ends_with("From 2019-07-03 (Wednesday) to 2019-07-05 (Friday): 2 days."),
"{a}"
);
}
#[test]
fn two_close_times_include_the_hours() {
let out = date_info(
&json!({"date": "2019-07-03 22:47", "other_date": "2019-07-04 08:40"}),
now(),
NY,
);
assert!(
out.ends_with("2019-07-04 08:40 (Thursday): 1 day (9h 53m apart)."),
"{out}"
);
}
#[test]
fn add_days_moves_forward_and_back() {
let out = date_info(&json!({"date": "2019-07-03", "add_days": 90}), now(), NY);
assert!(
out.ends_with("90 days after 2019-07-03 (Wednesday) is 2019-10-01 (Tuesday)."),
"{out}"
);
let out = date_info(&json!({"date": "2024-03-01", "add_days": -1}), now(), NY);
assert!(out.ends_with("is 2024-02-29 (Thursday)."), "{out}");
}
#[test]
fn calendar_gap_counts_like_people_do() {
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
// Month-end clamps: Jan 31 + 1 month = Feb 29 in a leap year.
assert_eq!(calendar_gap(d(2020, 1, 31), d(2020, 2, 29)), (0, 1, 0));
assert_eq!(calendar_gap(d(2020, 1, 31), d(2020, 3, 1)), (0, 1, 1));
assert_eq!(calendar_gap(d(2016, 2, 29), d(2026, 2, 28)), (10, 0, 0));
assert_eq!(calendar_gap(d(2019, 7, 3), d(2026, 10, 18)), (7, 3, 15));
assert_eq!(describe_gap(d(2025, 10, 18), d(2026, 10, 18)), "1 year");
}
#[test]
fn bad_input_says_what_format_to_use() {
let out = date_info(&json!({"date": "last tuesday"}), now(), NY);
assert!(
out.starts_with("Error:") && out.contains("YYYY-MM-DD"),
"{out}"
);
}
}
+25 -21
View File
@@ -1937,7 +1937,9 @@ Return ONLY the summary, nothing else."#,
self.tool_supersede_fact(arguments, user_id, persona_id, model, backend_label)
.await
}
"get_current_datetime" => Self::tool_get_current_datetime(),
"date_info" => {
crate::ai::date_info::date_info(arguments, Utc::now(), self.display_zone())
}
"get_thread_messages" => self.tool_get_thread_messages(arguments).await,
"look_at_message_image" => {
self.tool_look_at_message_image(arguments, backend.local())
@@ -3703,21 +3705,11 @@ Return ONLY the summary, nothing else."#,
}
}
/// Tool: get_current_datetime — returns the current local date and time
fn tool_get_current_datetime() -> String {
let now = Local::now();
format!(
"Current date/time: {} ({})",
now.format("%Y-%m-%d %H:%M:%S %Z"),
now.format("%A")
)
}
// ── Agentic insight generation ──────────────────────────────────────
/// Build the list of tool definitions for the agentic loop, gated by
/// `opts`. Always-on tools: `search_messages`, `get_sms_messages`,
/// `get_file_tags`, `reverse_geocode`, `get_current_datetime`, the
/// `get_file_tags`, `reverse_geocode`, `date_info`, the
/// five knowledge-memory tools. Conditional: `describe_photo` (vision
/// model), `get_personal_place_at` (Apollo configured), `search_rag`
/// (daily_summaries populated), `get_calendar_events` (calendar
@@ -4043,11 +4035,23 @@ Return ONLY the summary, nothing else."#,
}
tools.push(Tool::function(
"get_current_datetime",
"Get the current date and time. Useful when reasoning about how long ago a photo was taken.",
"date_info",
"Exact date arithmetic — use it instead of working dates out yourself. With no arguments: \
the current date and time. With `date`: that day's weekday and how long ago (or until) it \
is. With `date` and `other_date`: the gap between them in days, weeks and years/months/days \
(and hours, when both have times). With `date` and `add_days`: the date that many days \
after (or, negative, before). Dates are YYYY-MM-DD, YYYY-MM-DD HH:MM, or unix seconds, in \
the user's local time. \
Examples: `{}` — now. `{date: \"2019-07-03\"}` — weekday and how long ago. \
`{date: \"2019-07-03\", other_date: \"2019-08-14\"}` — time between. \
`{date: \"2019-07-03\", add_days: 90}` — the date 90 days later.",
serde_json::json!({
"type": "object",
"properties": {}
"properties": {
"date": { "type": "string", "description": "YYYY-MM-DD, YYYY-MM-DD HH:MM, or unix seconds." },
"other_date": { "type": "string", "description": "Second date, to measure the gap between the two." },
"add_days": { "type": "integer", "description": "Days to add to `date` (negative to go back)." }
}
}),
));
@@ -4305,7 +4309,7 @@ Return ONLY the summary, nothing else."#,
.find_map(|tok| tok.strip_prefix("ID:"))
.map(|id| format!("stored id={}", id.trim_end_matches(',')))
.unwrap_or_else(|| "stored".to_string()),
"get_current_datetime" => "time noted".to_string(),
"date_info" => "date worked out".to_string(),
"look_at_message_image" | "look_at_photo" => {
let short: String = raw.chars().take(80).collect();
format!("looked: \"{}...\"", short)
@@ -5238,7 +5242,7 @@ mod tests {
assert!(names.contains(&"get_sms_messages"));
assert!(names.contains(&"get_file_tags"));
assert!(names.contains(&"reverse_geocode"));
assert!(names.contains(&"get_current_datetime"));
assert!(names.contains(&"date_info"));
assert!(names.contains(&"recall_entities"));
assert!(names.contains(&"recall_facts_for_photo"));
assert!(names.contains(&"recall_facts_for_entity"));
@@ -5786,13 +5790,13 @@ mod tests {
}
#[test]
fn summarize_current_datetime() {
fn summarize_date_info() {
assert_eq!(
InsightGenerator::summarize_tool_result(
"get_current_datetime",
"Current date/time: 2024-01-15 12:00:00 PST (Monday)"
"date_info",
"Now: 2024-01-15 12:00 (Monday), America/New_York"
),
"time noted"
"date worked out"
);
}
+1
View File
@@ -2,6 +2,7 @@ pub mod apollo_client;
pub mod backend;
pub mod clip_client;
pub mod daily_summary_job;
pub mod date_info;
pub mod face_client;
pub mod gpu;
pub mod handlers;