cc-transcript 10.3.0__tar.gz → 10.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/PKG-INFO +1 -1
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/activity.py +7 -2
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/activity_probe.py +13 -10
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/filterspec.py +5 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/models.py +4 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/parser.py +2 -1
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/pyproject.toml +1 -1
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/activity.rs +84 -15
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/event.rs +3 -1
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/mining.rs +3 -13
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/protocol.rs +37 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/types.rs +18 -2
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/Cargo.lock +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/Cargo.toml +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/LICENSE +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/README.md +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/__init__.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/__main__.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/backend.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/builders.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/cli.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/command.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/context.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/corrections.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/corrections_cli.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/cost.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/decisions.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/discovery.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/evidence.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/extract/__init__.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/extract/correct.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/facts.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/ids.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/__init__.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/llm.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/similar.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/verdicts.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/__init__.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/engine.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/signals.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/sourcekind.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/spec.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/notifications.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/py.typed +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/query.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/render.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/rust.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/store.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/tools.py +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/Cargo.toml +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/build.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/command.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/filter.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/lexicon.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/lib.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/model.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/parse.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/python.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/score.rs +0 -0
- {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/value.rs +0 -0
|
@@ -40,10 +40,15 @@ def native_user_classifier(event: UserEvent) -> bool:
|
|
|
40
40
|
"""Whether a user event is a real prompt under native Claude Code semantics.
|
|
41
41
|
|
|
42
42
|
A prompt is non-meta, non-sidechain, not a compact summary, not an
|
|
43
|
-
interruption marker,
|
|
43
|
+
interruption marker, not an agent-injected relay banner, and not
|
|
44
|
+
tool-result-only — it must carry real text.
|
|
44
45
|
"""
|
|
45
46
|
return not (
|
|
46
|
-
event.meta.is_meta
|
|
47
|
+
event.meta.is_meta
|
|
48
|
+
or event.meta.is_sidechain
|
|
49
|
+
or event.meta.is_compact_summary
|
|
50
|
+
or event.interrupted
|
|
51
|
+
or event.is_agent_injected
|
|
47
52
|
) and bool(event.text.strip())
|
|
48
53
|
|
|
49
54
|
|
|
@@ -10,8 +10,8 @@ stays identical to. The verdict spans undelivered task notifications (the
|
|
|
10
10
|
turn, and pending async launches (async Agent/Task, live Workflow) session-wide — a
|
|
11
11
|
launch counts as completed only once its notification was delivered or drained, never
|
|
12
12
|
when merely enqueued — and flags unanswered non-human-facing tool calls in the current
|
|
13
|
-
turn as mid-tool. Compact-summary user lines do not open turns,
|
|
14
|
-
|
|
13
|
+
turn as mid-tool. Compact-summary user lines do not open turns, matching captain-hook,
|
|
14
|
+
which consumes this probe since the 10.2.0 shared-classifier fix.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
@@ -30,6 +30,7 @@ from cc_transcript.models import (
|
|
|
30
30
|
)
|
|
31
31
|
from cc_transcript.notifications import Notifications
|
|
32
32
|
from cc_transcript.parser import parse_events_from_bytes
|
|
33
|
+
from cc_transcript.tools import expand_tool_names, matches_names
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
35
36
|
from collections.abc import Sequence
|
|
@@ -39,7 +40,10 @@ if TYPE_CHECKING:
|
|
|
39
40
|
from cc_transcript.models import TranscriptEvent
|
|
40
41
|
|
|
41
42
|
DEFAULT_WAITING_TOOLS = frozenset({"Monitor", "ScheduleWakeup", "SendMessage", "TeamCreate"})
|
|
42
|
-
DEFAULT_HUMAN_FACING_TOOLS =
|
|
43
|
+
DEFAULT_HUMAN_FACING_TOOLS = expand_tool_names("AskUserQuestion|ExitPlanMode")
|
|
44
|
+
|
|
45
|
+
BACKGROUND_TOOLS = expand_tool_names("Agent|Task|Bash")
|
|
46
|
+
TASK_TOOLS = expand_tool_names("Agent|Task")
|
|
43
47
|
|
|
44
48
|
PendingKind = Literal[
|
|
45
49
|
"waiting_tool",
|
|
@@ -85,13 +89,12 @@ class SessionActivityProbe:
|
|
|
85
89
|
|
|
86
90
|
|
|
87
91
|
def ephemeral_wait(block: ToolUseBlock, waiting_tools: frozenset[str]) -> PendingKind | None:
|
|
88
|
-
if block.name
|
|
92
|
+
if matches_names(block.name, waiting_tools):
|
|
89
93
|
return "waiting_tool"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return "subagentless_task"
|
|
94
|
+
if matches_names(block.name, BACKGROUND_TOOLS) and block.input.get("run_in_background") is True:
|
|
95
|
+
return "background"
|
|
96
|
+
if matches_names(block.name, TASK_TOOLS) and not isinstance(block.input.get("subagent_type"), str):
|
|
97
|
+
return "subagentless_task"
|
|
95
98
|
return None
|
|
96
99
|
|
|
97
100
|
|
|
@@ -151,7 +154,7 @@ def probe_events(
|
|
|
151
154
|
waiting_kind = (ephemeral_wait(block, waiting_tools) if in_current_turn else None) or pending_async(
|
|
152
155
|
block, result, notifications
|
|
153
156
|
)
|
|
154
|
-
unmatched = in_current_turn and result is None and block.name
|
|
157
|
+
unmatched = in_current_turn and result is None and not matches_names(block.name, human_facing_tools)
|
|
155
158
|
is_waiting = is_waiting or waiting_kind is not None
|
|
156
159
|
mid_tool = mid_tool or unmatched
|
|
157
160
|
kind = waiting_kind or ("mid_tool" if unmatched else None)
|
|
@@ -419,6 +419,10 @@ def is_bare_interrupt_marker(text: str) -> bool:
|
|
|
419
419
|
return (marker := interrupt_marker(text)) is not None and not text.strip()[len(marker.strip()) :].strip()
|
|
420
420
|
|
|
421
421
|
|
|
422
|
+
def is_agent_injection(text: str) -> bool:
|
|
423
|
+
return AGENT_INJECTION_RE.search(text) is not None
|
|
424
|
+
|
|
425
|
+
|
|
422
426
|
def normalize_bare(text: str, strip_trailing: str = TRAILING_PUNCT) -> str:
|
|
423
427
|
return text.strip().rstrip(strip_trailing).strip().lower()
|
|
424
428
|
|
|
@@ -551,5 +555,6 @@ def predicate_to_dict(predicate: Predicate) -> dict[str, Any]:
|
|
|
551
555
|
|
|
552
556
|
|
|
553
557
|
STRUCTURAL_NOISE_RE = compile_groups(STRUCTURAL_NOISE_GROUPS, True)
|
|
558
|
+
AGENT_INJECTION_RE = compile_groups(AGENT_INJECTION_GROUPS, True)
|
|
554
559
|
INTERRUPT_MARKER_RE = compile_groups(INTERRUPT_MARKER_GROUPS, True)
|
|
555
560
|
JUNK_USER_MESSAGE_RE = compile_groups(SENTIMENT_JUNK_GROUPS, True)
|
|
@@ -160,12 +160,16 @@ class UserEvent:
|
|
|
160
160
|
text: The joined text of the turn.
|
|
161
161
|
blocks: The parsed content blocks, including tool results.
|
|
162
162
|
interrupted: Whether the turn is a user interruption.
|
|
163
|
+
is_agent_injected: Whether the turn is an agent-injected relay banner —
|
|
164
|
+
a teammate-message digest, scheduled-task banner, or foreign-agent
|
|
165
|
+
header — rather than an authored prompt.
|
|
163
166
|
"""
|
|
164
167
|
|
|
165
168
|
meta: EntryMeta
|
|
166
169
|
text: str
|
|
167
170
|
blocks: tuple[ContentBlock, ...]
|
|
168
171
|
interrupted: bool
|
|
172
|
+
is_agent_injected: bool = False
|
|
169
173
|
|
|
170
174
|
|
|
171
175
|
@dataclass(frozen=True, slots=True)
|
|
@@ -11,7 +11,7 @@ import anyio.to_thread
|
|
|
11
11
|
import orjson
|
|
12
12
|
|
|
13
13
|
from cc_transcript.backend import Backend, ParsedTranscript
|
|
14
|
-
from cc_transcript.filterspec import apply_spec, interrupt_marker
|
|
14
|
+
from cc_transcript.filterspec import apply_spec, interrupt_marker, is_agent_injection
|
|
15
15
|
from cc_transcript.models import (
|
|
16
16
|
AssistantEvent,
|
|
17
17
|
CacheCreation,
|
|
@@ -158,6 +158,7 @@ def parse_event(data: Mapping[str, Any]) -> TranscriptEvent | None:
|
|
|
158
158
|
text=text,
|
|
159
159
|
blocks=blocks,
|
|
160
160
|
interrupted=interrupt_marker(text) is not None,
|
|
161
|
+
is_agent_injected=is_agent_injection(text),
|
|
161
162
|
)
|
|
162
163
|
case "assistant":
|
|
163
164
|
text, blocks = parse_assistant_blocks(data["message"]["content"])
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
use std::collections::{HashMap, HashSet};
|
|
2
2
|
|
|
3
|
-
use
|
|
3
|
+
use once_cell::sync::Lazy;
|
|
4
|
+
|
|
5
|
+
use crate::types::{matches_names, Entry, ToolResultBlock, ToolUseBlock, UserEntry};
|
|
4
6
|
use crate::value::{field, field_str};
|
|
5
7
|
|
|
6
8
|
const NOTIFICATION_MARKER: &str = "<task-notification>";
|
|
7
9
|
|
|
10
|
+
// tools.py expand_tool_names("Agent|Task|Bash") / ("Agent|Task"), pre-expanded
|
|
11
|
+
// here because the alias table never crosses the language boundary.
|
|
12
|
+
static BACKGROUND_TOOLS: Lazy<HashSet<String>> =
|
|
13
|
+
Lazy::new(|| HashSet::from(["Agent", "Task", "Bash", "Execute"].map(String::from)));
|
|
14
|
+
static TASK_TOOLS: Lazy<HashSet<String>> =
|
|
15
|
+
Lazy::new(|| HashSet::from(["Agent", "Task"].map(String::from)));
|
|
16
|
+
|
|
8
17
|
pub struct ActivityOpts {
|
|
9
18
|
pub waiting_tools: HashSet<String>,
|
|
10
19
|
pub human_facing_tools: HashSet<String>,
|
|
@@ -16,7 +25,11 @@ impl Default for ActivityOpts {
|
|
|
16
25
|
waiting_tools: HashSet::from(
|
|
17
26
|
["Monitor", "ScheduleWakeup", "SendMessage", "TeamCreate"].map(String::from),
|
|
18
27
|
),
|
|
19
|
-
|
|
28
|
+
// tools.py expand_tool_names("AskUserQuestion|ExitPlanMode"), pre-expanded
|
|
29
|
+
// here because the alias table never crosses the language boundary.
|
|
30
|
+
human_facing_tools: HashSet::from(
|
|
31
|
+
["AskUserQuestion", "ExitPlanMode", "ExitSpecMode"].map(String::from),
|
|
32
|
+
),
|
|
20
33
|
}
|
|
21
34
|
}
|
|
22
35
|
}
|
|
@@ -60,11 +73,16 @@ pub struct SessionActivity {
|
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
/// Whether a user entry opens a turn (activity.py ``native_user_classifier``):
|
|
63
|
-
/// a real prompt — non-meta, non-sidechain, not an interruption marker,
|
|
64
|
-
/// non-blank text. Compact-summary exclusion
|
|
65
|
-
/// which
|
|
76
|
+
/// a real prompt — non-meta, non-sidechain, not an interruption marker, not an
|
|
77
|
+
/// agent-injected relay banner, with non-blank text. Compact-summary exclusion
|
|
78
|
+
/// matches captain-hook, which consumes this probe since the 10.2.0
|
|
79
|
+
/// shared-classifier fix.
|
|
66
80
|
fn opens_turn(user: &UserEntry) -> bool {
|
|
67
|
-
!(user.meta.is_meta
|
|
81
|
+
!(user.meta.is_meta
|
|
82
|
+
|| user.meta.is_sidechain
|
|
83
|
+
|| user.meta.is_compact_summary
|
|
84
|
+
|| user.interrupted()
|
|
85
|
+
|| user.is_agent_injected())
|
|
68
86
|
&& !user.content.text().trim().is_empty()
|
|
69
87
|
}
|
|
70
88
|
|
|
@@ -78,18 +96,19 @@ fn current_turn_start(entries: &[Entry]) -> usize {
|
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/// conditions.py ``ephemeral_wait``: a waiting tool, a backgrounded
|
|
81
|
-
/// Agent/Task/Bash, or a subagentless Agent/Task
|
|
99
|
+
/// Agent/Task/Bash, or a subagentless Agent/Task — every name matched alias-
|
|
100
|
+
/// and MCP-prefix-aware (tools.py matches_names).
|
|
82
101
|
fn ephemeral_wait(tool_use: &ToolUseBlock, waiting_tools: &HashSet<String>) -> Option<PendingKind> {
|
|
83
|
-
if
|
|
102
|
+
if matches_names(&tool_use.name, waiting_tools) {
|
|
84
103
|
return Some(PendingKind::WaitingTool);
|
|
85
104
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
Some(PendingKind::Background)
|
|
89
|
-
}
|
|
90
|
-
"Agent" | "Task" if tool_use.subagent_type.is_none() => Some(PendingKind::SubagentlessTask),
|
|
91
|
-
_ => None,
|
|
105
|
+
if matches_names(&tool_use.name, &BACKGROUND_TOOLS) && tool_use.run_in_background == Some(true) {
|
|
106
|
+
return Some(PendingKind::Background);
|
|
92
107
|
}
|
|
108
|
+
if matches_names(&tool_use.name, &TASK_TOOLS) && tool_use.subagent_type.is_none() {
|
|
109
|
+
return Some(PendingKind::SubagentlessTask);
|
|
110
|
+
}
|
|
111
|
+
None
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
/// conditions.py ``pending_async``: an async Agent/Task launch or a live
|
|
@@ -217,7 +236,7 @@ pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivi
|
|
|
217
236
|
.or_else(|| pending_async(tool_use, result, ¬ifications));
|
|
218
237
|
let unmatched = in_current_turn
|
|
219
238
|
&& result.is_none()
|
|
220
|
-
&& !
|
|
239
|
+
&& !matches_names(&tool_use.name, &opts.human_facing_tools);
|
|
221
240
|
is_waiting |= waiting_kind.is_some();
|
|
222
241
|
mid_tool |= unmatched;
|
|
223
242
|
let Some(kind) = waiting_kind.or(unmatched.then_some(PendingKind::MidTool)) else {
|
|
@@ -496,6 +515,22 @@ mod tests {
|
|
|
496
515
|
assert_eq!(activity.pending[0].kind, PendingKind::Background);
|
|
497
516
|
}
|
|
498
517
|
|
|
518
|
+
#[test]
|
|
519
|
+
fn agent_injected_banner_does_not_open_turn() {
|
|
520
|
+
let entries = vec![
|
|
521
|
+
user("build it"),
|
|
522
|
+
tool_use("Bash", "b1", r#"{"command":"make","run_in_background":true}"#),
|
|
523
|
+
tool_result("b1"),
|
|
524
|
+
user("<teammate-message from='mate'>ping</teammate-message>"),
|
|
525
|
+
];
|
|
526
|
+
let activity = activity(&entries);
|
|
527
|
+
assert!(
|
|
528
|
+
activity.is_waiting,
|
|
529
|
+
"an agent-injected relay banner must not open a turn, so the background Bash stays current"
|
|
530
|
+
);
|
|
531
|
+
assert_eq!(activity.pending[0].kind, PendingKind::Background);
|
|
532
|
+
}
|
|
533
|
+
|
|
499
534
|
#[test]
|
|
500
535
|
fn unrelated_completion_marker_keeps_waiting() {
|
|
501
536
|
let entries = vec![
|
|
@@ -545,6 +580,17 @@ mod tests {
|
|
|
545
580
|
assert!(activity.pending.is_empty());
|
|
546
581
|
}
|
|
547
582
|
|
|
583
|
+
#[test]
|
|
584
|
+
fn background_execute_alias_in_current_turn_is_waiting() {
|
|
585
|
+
let entries = vec![
|
|
586
|
+
user("build it"),
|
|
587
|
+
tool_use("Execute", "e1", r#"{"command":"make","run_in_background":true}"#),
|
|
588
|
+
];
|
|
589
|
+
let activity = activity(&entries);
|
|
590
|
+
assert!(activity.is_waiting);
|
|
591
|
+
assert_eq!(activity.pending[0].kind, PendingKind::Background);
|
|
592
|
+
}
|
|
593
|
+
|
|
548
594
|
#[test]
|
|
549
595
|
fn subagentless_agent_is_waiting() {
|
|
550
596
|
let entries = vec![
|
|
@@ -601,6 +647,17 @@ mod tests {
|
|
|
601
647
|
assert_eq!(activity.pending[0].kind, PendingKind::WaitingTool);
|
|
602
648
|
}
|
|
603
649
|
|
|
650
|
+
#[test]
|
|
651
|
+
fn mcp_prefixed_waiting_tool_is_waiting() {
|
|
652
|
+
let entries = vec![
|
|
653
|
+
user("ping the pool"),
|
|
654
|
+
tool_use("mcp__pool__SendMessage", "s1", r#"{"text":"hi"}"#),
|
|
655
|
+
];
|
|
656
|
+
let activity = activity(&entries);
|
|
657
|
+
assert!(activity.is_waiting);
|
|
658
|
+
assert_eq!(activity.pending[0].kind, PendingKind::WaitingTool);
|
|
659
|
+
}
|
|
660
|
+
|
|
604
661
|
#[test]
|
|
605
662
|
fn pending_ask_user_question_is_quiet() {
|
|
606
663
|
let entries = vec![
|
|
@@ -613,6 +670,18 @@ mod tests {
|
|
|
613
670
|
assert!(activity.pending.is_empty());
|
|
614
671
|
}
|
|
615
672
|
|
|
673
|
+
#[test]
|
|
674
|
+
fn mcp_prefixed_human_facing_tool_is_not_mid_tool() {
|
|
675
|
+
let entries = vec![
|
|
676
|
+
user("choose"),
|
|
677
|
+
tool_use("mcp__someserver__AskUserQuestion", "q1", r#"{"questions":[]}"#),
|
|
678
|
+
];
|
|
679
|
+
let activity = activity(&entries);
|
|
680
|
+
assert!(!activity.is_waiting);
|
|
681
|
+
assert!(!activity.mid_tool);
|
|
682
|
+
assert!(activity.pending.is_empty());
|
|
683
|
+
}
|
|
684
|
+
|
|
616
685
|
#[test]
|
|
617
686
|
fn unmatched_bash_in_current_turn_is_mid_tool() {
|
|
618
687
|
let entries = vec![user("list"), tool_use("Bash", "b1", r#"{"command":"ls"}"#)];
|
|
@@ -12,7 +12,7 @@ use crate::model::{
|
|
|
12
12
|
USER_EVENT_CLS,
|
|
13
13
|
};
|
|
14
14
|
use crate::parse::ParseError;
|
|
15
|
-
use crate::protocol::interrupt_marker;
|
|
15
|
+
use crate::protocol::{interrupt_marker, is_agent_injection};
|
|
16
16
|
use crate::types::{
|
|
17
17
|
joined_text, ContentBlock, Entry, EntryMeta, InitInfo, ModelUsage, PrintBody, PrintMessage,
|
|
18
18
|
PrintResult, Usage, UserContent,
|
|
@@ -155,11 +155,13 @@ pub fn build_event<'py>(py: Python<'py>, entry: &Entry) -> PyResult<Bound<'py, P
|
|
|
155
155
|
Entry::User(user) => {
|
|
156
156
|
let text = user.content.text();
|
|
157
157
|
let interrupted = interrupt_marker(&text).is_some();
|
|
158
|
+
let is_agent_injected = is_agent_injection(&text);
|
|
158
159
|
models_type(py, &USER_EVENT_CLS, "UserEvent")?.call1((
|
|
159
160
|
build_meta(py, &user.meta)?,
|
|
160
161
|
text,
|
|
161
162
|
build_user_blocks(py, &user.content)?,
|
|
162
163
|
interrupted,
|
|
164
|
+
is_agent_injected,
|
|
163
165
|
))
|
|
164
166
|
}
|
|
165
167
|
Entry::Assistant(assistant) => {
|
|
@@ -13,7 +13,9 @@ use crate::protocol::{
|
|
|
13
13
|
embedded_user_text, interrupt_marker, is_bare_interrupt_marker, ANSWERED_PREFIX, ANSWERED_TRAILER,
|
|
14
14
|
DENIAL_PREFIX, INTERRUPT_MARKER_RE,
|
|
15
15
|
};
|
|
16
|
-
use crate::types::{
|
|
16
|
+
use crate::types::{
|
|
17
|
+
matches_names, tool_use_index, Entry, EntryMeta, Question, ToolResultBlock, ToolUseBlock, UserEntry,
|
|
18
|
+
};
|
|
17
19
|
use crate::value::{field, field_bool, field_str};
|
|
18
20
|
|
|
19
21
|
// Source kinds (mining/sourcekind.py).
|
|
@@ -478,18 +480,6 @@ impl Events {
|
|
|
478
480
|
}
|
|
479
481
|
}
|
|
480
482
|
|
|
481
|
-
/// matches_names (tools.py matches_names): exact membership, or the ``<tool>``
|
|
482
|
-
/// segment of an ``mcp__<server>__<tool>`` name split on the first two ``__``.
|
|
483
|
-
/// Alias closure happens Python-side (tools.py expand_tool_names); the spec JSON
|
|
484
|
-
/// arrives pre-expanded and the alias table never crosses the language boundary.
|
|
485
|
-
fn matches_names(actual: &str, names: &HashSet<String>) -> bool {
|
|
486
|
-
names.contains(actual)
|
|
487
|
-
|| actual
|
|
488
|
-
.strip_prefix("mcp__")
|
|
489
|
-
.and_then(|rest| rest.split_once("__"))
|
|
490
|
-
.is_some_and(|(_, tool)| names.contains(tool))
|
|
491
|
-
}
|
|
492
|
-
|
|
493
483
|
// ── text-shape helpers (mining/signals.py) ───────────────────────────────────
|
|
494
484
|
|
|
495
485
|
/// marker_in (mining/signals.py marker_in): whether any tool-result block's
|
|
@@ -12,6 +12,12 @@ pub const ANSWERED_PREFIX: &str = "Your questions have been answered: ";
|
|
|
12
12
|
pub const ANSWERED_TRAILER: &str = ". You can now continue with these answers in mind.";
|
|
13
13
|
const INTERRUPT_MARKER_PATTERN: &str = r"^\s*\[Request interrupted by user";
|
|
14
14
|
|
|
15
|
+
// Agent-injected relay banners (filterspec.py AGENT_INJECTION_GROUPS): teammate-message
|
|
16
|
+
// digests, scheduled-task banners, and foreign-agent headers. The alternation mirrors
|
|
17
|
+
// compile_groups(AGENT_INJECTION_GROUPS): `(?:p1)|(?:p2)|(?:p3)`.
|
|
18
|
+
const AGENT_INJECTION_PATTERN: &str =
|
|
19
|
+
r"(?:<(?:teammate-message|scheduled-task)\b)|(?:^# Augment Agent\b)|(?:^\s*\[Role Reminder\b)";
|
|
20
|
+
|
|
15
21
|
/// The one interrupt-marker regex (filterspec.py INTERRUPT_MARKER_RE): the pattern
|
|
16
22
|
/// compiled case-insensitively, anchored at the start of the haystack with leading
|
|
17
23
|
/// whitespace tolerated. Shared with mining's structural fallback so both uses
|
|
@@ -20,6 +26,19 @@ pub static INTERRUPT_MARKER_RE: Lazy<Regex> = Lazy::new(|| {
|
|
|
20
26
|
Regex::new(&format!("(?i){INTERRUPT_MARKER_PATTERN}")).expect("interrupt regex")
|
|
21
27
|
});
|
|
22
28
|
|
|
29
|
+
/// The agent-injection regex (filterspec.py AGENT_INJECTION_RE): the group
|
|
30
|
+
/// alternation compiled case-insensitively, matched with search semantics
|
|
31
|
+
/// (filterspec.py is_agent_injection uses .search()).
|
|
32
|
+
pub static AGENT_INJECTION_RE: Lazy<Regex> = Lazy::new(|| {
|
|
33
|
+
Regex::new(&format!("(?i){AGENT_INJECTION_PATTERN}")).expect("agent injection regex")
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/// is_agent_injection (filterspec.py is_agent_injection): whether ``text`` is an
|
|
37
|
+
/// agent-injected relay banner rather than an authored prompt.
|
|
38
|
+
pub fn is_agent_injection(text: &str) -> bool {
|
|
39
|
+
AGENT_INJECTION_RE.is_match(text)
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
/// embedded_user_text (filterspec.py embedded_user_text): the verbatim instruction
|
|
24
43
|
/// wrapped between the USER_SAID markers, or None.
|
|
25
44
|
pub fn embedded_user_text(content: &str) -> Option<String> {
|
|
@@ -91,4 +110,22 @@ mod tests {
|
|
|
91
110
|
assert!(is_bare_interrupt_marker("[Request interrupted by user]"));
|
|
92
111
|
assert!(!is_bare_interrupt_marker("[Request interrupted by user] no do it differently"));
|
|
93
112
|
}
|
|
113
|
+
|
|
114
|
+
#[test]
|
|
115
|
+
fn agent_injection_matches_relay_banners() {
|
|
116
|
+
assert!(is_agent_injection("<teammate-message from='r'>rebase</teammate-message>"));
|
|
117
|
+
assert!(is_agent_injection("<scheduled-task id='7'>run the suite</scheduled-task>"));
|
|
118
|
+
assert!(is_agent_injection("[Role Reminder: You are a Coordinator."));
|
|
119
|
+
assert!(is_agent_injection("# Augment Agent\nyou have these tools"));
|
|
120
|
+
// search() is unanchored: a mid-text teammate tag still matches.
|
|
121
|
+
assert!(is_agent_injection("as noted in the <teammate-message> above"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#[test]
|
|
125
|
+
fn agent_injection_rejects_prose_and_head_anchored_mid_text() {
|
|
126
|
+
assert!(!is_agent_injection("remind me what the teammate coordinator does"));
|
|
127
|
+
// the head-anchored role-reminder group does not match mid-text.
|
|
128
|
+
assert!(!is_agent_injection("we discussed the [Role Reminder] banner mid-sentence"));
|
|
129
|
+
assert!(!is_agent_injection(""));
|
|
130
|
+
}
|
|
94
131
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
use std::collections::HashMap;
|
|
1
|
+
use std::collections::{HashMap, HashSet};
|
|
2
2
|
|
|
3
3
|
use chrono::{DateTime, FixedOffset};
|
|
4
4
|
use sonic_rs::Value;
|
|
5
5
|
|
|
6
|
-
use crate::protocol::interrupt_marker;
|
|
6
|
+
use crate::protocol::{interrupt_marker, is_agent_injection};
|
|
7
7
|
|
|
8
8
|
/// Envelope metadata shared by the conversational entry kinds (user, assistant,
|
|
9
9
|
/// system). Mode and Other entries carry no envelope on disk.
|
|
@@ -116,6 +116,10 @@ impl UserEntry {
|
|
|
116
116
|
pub fn interrupted(&self) -> bool {
|
|
117
117
|
interrupt_marker(&self.content.text()).is_some()
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
pub fn is_agent_injected(&self) -> bool {
|
|
121
|
+
is_agent_injection(&self.content.text())
|
|
122
|
+
}
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
#[derive(Debug)]
|
|
@@ -225,6 +229,18 @@ pub fn tool_use_index(entries: &[Entry]) -> HashMap<&str, &ToolUseBlock> {
|
|
|
225
229
|
.collect()
|
|
226
230
|
}
|
|
227
231
|
|
|
232
|
+
/// matches_names (tools.py matches_names): exact membership, or the ``<tool>``
|
|
233
|
+
/// segment of an ``mcp__<server>__<tool>`` name split on the first two ``__``.
|
|
234
|
+
/// Alias closure happens Python-side (tools.py expand_tool_names); spec JSON
|
|
235
|
+
/// arrives pre-expanded and the alias table never crosses the language boundary.
|
|
236
|
+
pub(crate) fn matches_names(actual: &str, names: &HashSet<String>) -> bool {
|
|
237
|
+
names.contains(actual)
|
|
238
|
+
|| actual
|
|
239
|
+
.strip_prefix("mcp__")
|
|
240
|
+
.and_then(|rest| rest.split_once("__"))
|
|
241
|
+
.is_some_and(|(_, tool)| names.contains(tool))
|
|
242
|
+
}
|
|
243
|
+
|
|
228
244
|
/// The space-joined text of the ``Text`` blocks — the ``text`` field of the
|
|
229
245
|
/// Python ``AssistantEvent``/``PrintMessage``.
|
|
230
246
|
pub fn joined_text(blocks: &[ContentBlock]) -> String {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|