Skip to content

Commit 3061e6a

Browse files
committed
feat(analytics): record the task summary alongside the declared category
name_session now attaches the PII-scrubbed task summary to the agent_session_task_declared event so the summary is available for product analytics, not only local audit search. The analytics catalog gains its first free-text property: task_summary is optional (the declaration still sends without it) and length-bounded defensively at the boundary, with the scrub and cap already applied upstream. Update the tool description to note the summary powers search and is recorded for analytics.
1 parent e78f75a commit 3061e6a

3 files changed

Lines changed: 119 additions & 23 deletions

File tree

packages/browseros-agent/apps/claw-server-rust/src/analytics/events.rs

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
//!
33
//! Producers can select one of these opaque definitions, but cannot construct a
44
//! new wire event or widen its property schema. Free-form input is reduced to
5-
//! fixed tokens here before the delivery service ever sees it.
5+
//! fixed tokens here before the delivery service ever sees it. The sole
6+
//! exception is the agent-declared `task_summary`, a free-text property that is
7+
//! PII-scrubbed and length-capped upstream and only bounded defensively here.
68
79
use serde_json::{Map, Value};
810
use std::{
@@ -12,6 +14,7 @@ use std::{
1214

1315
const CLIENT_NAME: &str = "client_name";
1416
const TASK_CATEGORY: &str = "task_category";
17+
const TASK_SUMMARY: &str = "task_summary";
1518
const HARNESS: &str = "harness";
1619
const KIND: &str = "kind";
1720
const TOOL_NAME: &str = "tool_name";
@@ -34,6 +37,11 @@ const SCREENSHOT_TOKENS_PER_DISPATCH: &str = "screenshot_tokens_per_dispatch";
3437

3538
pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
3639

40+
/// Hard cap on the free-text task summary before it leaves the machine. The
41+
/// summary is already PII-scrubbed and length-capped upstream; this is the
42+
/// final defensive bound at the analytics boundary.
43+
pub(crate) const TASK_SUMMARY_MAX_CHARS: usize = 200;
44+
3745
const KNOWN_CLIENTS: [&str; 15] = [
3846
"claude-desktop",
3947
"claude-code",
@@ -95,6 +103,7 @@ pub(crate) const TASK_CATEGORY_VALUES: [&str; 11] = [
95103
enum PropertyKind {
96104
ClientName,
97105
TaskCategory,
106+
TaskSummary,
98107
Harness,
99108
EndKind,
100109
ToolName,
@@ -106,11 +115,26 @@ enum PropertyKind {
106115
struct PropertyDefinition {
107116
name: &'static str,
108117
kind: PropertyKind,
118+
/// When true, the event still validates and sends if this property is
119+
/// absent; when present it must still normalize.
120+
optional: bool,
109121
}
110122

111123
impl PropertyDefinition {
112124
const fn new(name: &'static str, kind: PropertyKind) -> Self {
113-
Self { name, kind }
125+
Self {
126+
name,
127+
kind,
128+
optional: false,
129+
}
130+
}
131+
132+
const fn optional(name: &'static str, kind: PropertyKind) -> Self {
133+
Self {
134+
name,
135+
kind,
136+
optional: true,
137+
}
114138
}
115139

116140
fn normalize(self, value: &Value) -> Option<Value> {
@@ -125,6 +149,15 @@ impl PropertyDefinition {
125149
};
126150
Some(Value::String(category.to_string()))
127151
}
152+
PropertyKind::TaskSummary => {
153+
// The one free-text property: the agent-authored summary is already
154+
// PII-scrubbed and length-capped upstream; here it is only bounded
155+
// defensively and passed through as-is.
156+
let raw = value.as_str()?;
157+
Some(Value::String(
158+
raw.chars().take(TASK_SUMMARY_MAX_CHARS).collect(),
159+
))
160+
}
128161
PropertyKind::Harness => normalize_token(value, &HARNESS_VALUES),
129162
PropertyKind::EndKind => normalize_token(value, &END_KIND_VALUES),
130163
PropertyKind::ToolName => {
@@ -193,8 +226,19 @@ impl EventDefinition {
193226
let input = properties.as_object()?;
194227
let mut output = Map::new();
195228
for property in self.properties {
196-
let value = input.get(property.name)?;
197-
output.insert(property.name.to_string(), property.normalize(value)?);
229+
let Some(value) = input.get(property.name) else {
230+
if property.optional {
231+
continue;
232+
}
233+
return None;
234+
};
235+
let Some(normalized) = property.normalize(value) else {
236+
if property.optional {
237+
continue;
238+
}
239+
return None;
240+
};
241+
output.insert(property.name.to_string(), normalized);
198242
}
199243
Some(Value::Object(output))
200244
}
@@ -204,10 +248,11 @@ impl EventDefinition {
204248
properties: &HashMap<String, Value>,
205249
) -> bool {
206250
self.properties.iter().all(|property| {
207-
let Some(current) = properties.get(property.name) else {
208-
return false;
209-
};
210-
property.normalize(current).as_ref() == Some(current)
251+
match properties.get(property.name) {
252+
Some(current) => property.normalize(current).as_ref() == Some(current),
253+
// Absent is acceptable only for optional properties.
254+
None => property.optional,
255+
}
211256
})
212257
}
213258

@@ -229,6 +274,7 @@ pub const AGENT_SESSION_TASK_DECLARED: EventDefinition = EventDefinition::new(
229274
&[
230275
PropertyDefinition::new(TASK_CATEGORY, PropertyKind::TaskCategory),
231276
PropertyDefinition::new(CLIENT_NAME, PropertyKind::ClientName),
277+
PropertyDefinition::optional(TASK_SUMMARY, PropertyKind::TaskSummary),
232278
],
233279
);
234280
pub const AGENT_SESSION_ENDED: EventDefinition = EventDefinition::new(
@@ -375,7 +421,7 @@ mod tests {
375421
);
376422
assert_eq!(
377423
AGENT_SESSION_TASK_DECLARED.property_names(),
378-
vec!["task_category", "client_name"]
424+
vec!["task_category", "client_name", "task_summary"]
379425
);
380426
assert_eq!(
381427
AGENT_SESSION_ENDED.property_names(),
@@ -521,6 +567,47 @@ mod tests {
521567
);
522568
}
523569

570+
#[test]
571+
fn task_declared_passes_through_the_free_text_summary() {
572+
assert_eq!(
573+
AGENT_SESSION_TASK_DECLARED.sanitize(&json!({
574+
"task_category": "shopping",
575+
"client_name": "cursor",
576+
"task_summary": "Compared warranty terms across three retailers.",
577+
})),
578+
Some(json!({
579+
"task_category": "shopping",
580+
"client_name": "cursor",
581+
"task_summary": "Compared warranty terms across three retailers.",
582+
}))
583+
);
584+
}
585+
586+
#[test]
587+
fn task_declared_still_sends_when_the_optional_summary_is_absent() {
588+
assert_eq!(
589+
AGENT_SESSION_TASK_DECLARED
590+
.sanitize(&json!({ "task_category": "research", "client_name": "codex" })),
591+
Some(json!({ "task_category": "research", "client_name": "codex" }))
592+
);
593+
}
594+
595+
#[test]
596+
fn task_summary_is_capped_at_the_boundary() {
597+
let long = "x".repeat(TASK_SUMMARY_MAX_CHARS + 50);
598+
let sanitized = AGENT_SESSION_TASK_DECLARED.sanitize(&json!({
599+
"task_category": "other",
600+
"client_name": "codex",
601+
"task_summary": long,
602+
}));
603+
let summary = sanitized
604+
.as_ref()
605+
.and_then(|value| value.get("task_summary"))
606+
.and_then(Value::as_str)
607+
.unwrap_or_default();
608+
assert_eq!(summary.chars().count(), TASK_SUMMARY_MAX_CHARS);
609+
}
610+
524611
#[test]
525612
fn unlisted_client_names_surface_their_slug() {
526613
for (raw, expected) in [

packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ use uuid::Uuid;
4646
const SERVER_NAME: &str = "browseros-neo";
4747
const SERVER_TITLE: &str = "BrowserOS neo";
4848
const NAME_SESSION_TOOL_NAME: &str = "name_session";
49-
const NAME_SESSION_DESCRIPTION: &str = "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as <client>/<name>; the label and summary stay on this machine (the summary makes the session findable in audit search), and only the category is used for anonymous aggregate analytics. Call again to update.";
49+
const NAME_SESSION_DESCRIPTION: &str = "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as <client>/<name>; the label stays on this machine, the summary powers audit search and is also recorded for analytics, and the category is used for anonymous aggregate analytics. Call again to update.";
5050
const NAME_SESSION_CATEGORY_DESCRIPTION: &str = "The kind of task, for anonymous aggregate analytics only; the free-form name is never sent. Pick the closest fit from the list.";
5151
const NAME_SESSION_SUMMARY_DESCRIPTION: &str = "One or two short lines saying what this task is, phrased so you can find it again by searching later. No names, emails, URLs, file paths, or account numbers.";
5252
const SUMMARY_MAX_LEN: usize = 200;
@@ -146,6 +146,14 @@ impl ClawMcpService {
146146
.into_call_tool_result();
147147
}
148148
};
149+
// Scrub structural PII from any provided summary before it is stored, indexed for
150+
// search, or recorded on the task-declared analytics event. Last write wins locally.
151+
let scrubbed_summary = raw_args
152+
.get("summary")
153+
.and_then(Value::as_str)
154+
.map(str::trim)
155+
.filter(|summary| !summary.is_empty())
156+
.map(scrub_summary);
149157
if let Some(category) = raw_args
150158
.get("category")
151159
.and_then(Value::as_str)
@@ -155,23 +163,24 @@ impl ClawMcpService {
155163
// At most once per session: a later name_session rename must not
156164
// re-declare and overcount the category mix or the declaration rate.
157165
if started.session.try_mark_task_declared() {
166+
let mut properties = json!({
167+
"task_category": category,
168+
"client_name": started.session.client_name(),
169+
});
170+
// The scrubbed summary rides along with the category declaration; the
171+
// analytics layer bounds it defensively before it leaves the machine.
172+
if let Some(summary) = scrubbed_summary
173+
.as_deref()
174+
.filter(|summary| !summary.is_empty())
175+
{
176+
properties["task_summary"] = Value::String(summary.to_string());
177+
}
158178
self.state.analytics.capture(
159179
crate::analytics::events::AGENT_SESSION_TASK_DECLARED,
160-
json!({
161-
"task_category": category,
162-
"client_name": started.session.client_name(),
163-
}),
180+
properties,
164181
);
165182
}
166183
}
167-
// Scrub structural PII from any provided summary before it is persisted or
168-
// indexed for search; stored locally only, never sent to analytics. Last write wins.
169-
let scrubbed_summary = raw_args
170-
.get("summary")
171-
.and_then(Value::as_str)
172-
.map(str::trim)
173-
.filter(|summary| !summary.is_empty())
174-
.map(scrub_summary);
175184
if let Some(clean) = scrubbed_summary.as_deref()
176185
&& !clean.is_empty()
177186
&& let Err(error) = self

packages/browseros-agent/apps/claw-server-rust/tests/routes.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ async fn mcp_name_session_lists_and_renames_while_disconnected() -> anyhow::Resu
487487
.ok_or_else(|| anyhow::anyhow!("name_session missing"))?;
488488
assert_eq!(
489489
tool["description"],
490-
"Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as <client>/<name>; the label and summary stay on this machine (the summary makes the session findable in audit search), and only the category is used for anonymous aggregate analytics. Call again to update."
490+
"Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as <client>/<name>; the label stays on this machine, the summary powers audit search and is also recorded for analytics, and the category is used for anonymous aggregate analytics. Call again to update."
491491
);
492492
assert_eq!(
493493
tool["inputSchema"],

0 commit comments

Comments
 (0)