fast-resume 1.12.8__py3-none-any.whl

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.
@@ -0,0 +1,128 @@
1
+ """Filter bar widget for agent selection."""
2
+
3
+ from textual.containers import Horizontal
4
+ from textual.events import Click
5
+ from textual.message import Message
6
+ from textual.reactive import reactive
7
+ from textual.widgets import Label
8
+ from textual_image.widget import Image as ImageWidget
9
+
10
+ from ..config import AGENTS
11
+ from .utils import ASSETS_DIR
12
+
13
+ # Filter keys in display order (None = "All")
14
+ FILTER_KEYS: list[str | None] = [
15
+ None,
16
+ "claude",
17
+ "codex",
18
+ "copilot-cli",
19
+ "copilot-vscode",
20
+ "crush",
21
+ "opencode",
22
+ "vibe",
23
+ ]
24
+
25
+ # Map button IDs to filter values
26
+ _FILTER_ID_MAP: dict[str, str | None] = {
27
+ f"filter-{key or 'all'}": key for key in FILTER_KEYS
28
+ }
29
+
30
+
31
+ class FilterBar(Horizontal):
32
+ """A horizontal bar of filter buttons for agent selection.
33
+
34
+ Emits FilterBar.Changed when filter selection changes.
35
+ """
36
+
37
+ class Changed(Message):
38
+ """Posted when the active filter changes."""
39
+
40
+ def __init__(self, filter_key: str | None) -> None:
41
+ self.filter_key = filter_key
42
+ super().__init__()
43
+
44
+ active_filter: reactive[str | None] = reactive(None)
45
+
46
+ def __init__(
47
+ self,
48
+ initial_filter: str | None = None,
49
+ id: str | None = None,
50
+ ) -> None:
51
+ super().__init__(id=id)
52
+ self._initial_filter = initial_filter
53
+ self._filter_buttons: dict[str | None, Horizontal] = {}
54
+
55
+ def compose(self):
56
+ """Create filter buttons."""
57
+ for filter_key in FILTER_KEYS:
58
+ filter_label = AGENTS[filter_key]["badge"] if filter_key else "All"
59
+ btn_id = f"filter-{filter_key or 'all'}"
60
+ with Horizontal(id=btn_id, classes="filter-btn") as btn_container:
61
+ if filter_key:
62
+ icon_path = ASSETS_DIR / f"{filter_key}.png"
63
+ if icon_path.exists():
64
+ yield ImageWidget(icon_path, classes="filter-icon")
65
+ yield Label(
66
+ filter_label, classes=f"filter-label agent-{filter_key}"
67
+ )
68
+ else:
69
+ yield Label(filter_label, classes="filter-label")
70
+ self._filter_buttons[filter_key] = btn_container
71
+
72
+ def on_mount(self) -> None:
73
+ """Initialize active filter state."""
74
+ self.active_filter = self._initial_filter
75
+ self._update_button_styles()
76
+
77
+ def watch_active_filter(self, value: str | None) -> None:
78
+ """Update button styles when active filter changes."""
79
+ self._update_button_styles()
80
+
81
+ def _update_button_styles(self) -> None:
82
+ """Update filter button active states."""
83
+ for filter_key, btn in self._filter_buttons.items():
84
+ if filter_key == self.active_filter:
85
+ btn.add_class("-active")
86
+ else:
87
+ btn.remove_class("-active")
88
+
89
+ def set_active(self, filter_key: str | None, notify: bool = False) -> None:
90
+ """Set the active filter programmatically.
91
+
92
+ Args:
93
+ filter_key: The filter to activate, or None for "All".
94
+ notify: If True, emit a Changed message.
95
+ """
96
+ if filter_key != self.active_filter:
97
+ self.active_filter = filter_key
98
+ if notify:
99
+ self.post_message(self.Changed(filter_key))
100
+
101
+ def on_click(self, event: Click) -> None:
102
+ """Handle click on filter buttons."""
103
+ # Walk up to find the filter-btn container (click might be on child widget)
104
+ widget = event.widget
105
+ while widget and widget is not self:
106
+ if hasattr(widget, "classes") and "filter-btn" in widget.classes:
107
+ if widget.id in _FILTER_ID_MAP:
108
+ new_filter = _FILTER_ID_MAP[widget.id]
109
+ if new_filter != self.active_filter:
110
+ self.active_filter = new_filter
111
+ self.post_message(self.Changed(new_filter))
112
+ return
113
+ widget = widget.parent
114
+
115
+ def update_agents_with_sessions(self, agents: set[str]) -> None:
116
+ """Show only agents that have sessions.
117
+
118
+ Args:
119
+ agents: Set of agent names that have at least one session.
120
+ """
121
+ for filter_key, btn in self._filter_buttons.items():
122
+ if filter_key is None:
123
+ # "All" button is always visible
124
+ btn.display = True
125
+ elif filter_key in agents:
126
+ btn.display = True
127
+ else:
128
+ btn.display = False
@@ -0,0 +1,73 @@
1
+ """Modal dialogs for the TUI."""
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Label
9
+
10
+ from .styles import YOLO_MODAL_CSS
11
+
12
+
13
+ class YoloModeModal(ModalScreen[bool]):
14
+ """Modal to choose yolo mode for resume."""
15
+
16
+ BINDINGS = [
17
+ Binding("y", "select_yolo", "Yolo Mode", show=False),
18
+ Binding("n", "select_normal", "Normal", show=False),
19
+ Binding("escape", "dismiss", "Cancel", show=False),
20
+ Binding("enter", "select_focused", "Select", show=False),
21
+ Binding("left", "focus_normal", "Left", show=False),
22
+ Binding("right", "focus_yolo", "Right", show=False),
23
+ ]
24
+
25
+ CSS = YOLO_MODAL_CSS
26
+
27
+ def compose(self) -> ComposeResult:
28
+ with Vertical():
29
+ yield Label("Resume with yolo mode?", id="title")
30
+ with Horizontal(id="buttons"):
31
+ yield Button("No", id="normal-btn")
32
+ yield Button("Yolo", id="yolo-btn")
33
+
34
+ def on_mount(self) -> None:
35
+ """Focus the first button when modal opens."""
36
+ self.query_one("#normal-btn", Button).focus()
37
+
38
+ def action_toggle_focus(self) -> None:
39
+ """Toggle focus between the two buttons."""
40
+ if self.focused and self.focused.id == "yolo-btn":
41
+ self.query_one("#normal-btn", Button).focus()
42
+ else:
43
+ self.query_one("#yolo-btn", Button).focus()
44
+
45
+ def action_focus_normal(self) -> None:
46
+ """Focus the normal button."""
47
+ self.query_one("#normal-btn", Button).focus()
48
+
49
+ def action_focus_yolo(self) -> None:
50
+ """Focus the yolo button."""
51
+ self.query_one("#yolo-btn", Button).focus()
52
+
53
+ def action_select_focused(self) -> None:
54
+ """Select whichever button is currently focused."""
55
+ focused = self.focused
56
+ if focused and focused.id == "yolo-btn":
57
+ self.dismiss(True)
58
+ else:
59
+ self.dismiss(False)
60
+
61
+ def action_select_yolo(self) -> None:
62
+ self.dismiss(True)
63
+
64
+ def action_select_normal(self) -> None:
65
+ self.dismiss(False)
66
+
67
+ @on(Button.Pressed, "#yolo-btn")
68
+ def on_yolo_pressed(self) -> None:
69
+ self.dismiss(True)
70
+
71
+ @on(Button.Pressed, "#normal-btn")
72
+ def on_normal_pressed(self) -> None:
73
+ self.dismiss(False)
@@ -0,0 +1,396 @@
1
+ """Session preview widget for the TUI."""
2
+
3
+ import re
4
+ from io import StringIO
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rich.columns import Columns
9
+ from rich.console import Console, Group, RenderableType
10
+ from rich.markup import escape as escape_markup
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+ from textual.widgets import Static
14
+ from textual_image.renderable import Image as ImageRenderable
15
+
16
+ from ..adapters.base import Session
17
+ from ..config import AGENTS
18
+ from .utils import highlight_matches
19
+
20
+ # Asset paths for agent icons
21
+ ASSETS_DIR = Path(__file__).parent.parent / "assets"
22
+
23
+ # Cache for agent icon renderables
24
+ _preview_icon_cache: dict[str, Any] = {}
25
+
26
+
27
+ def _get_agent_icon(agent: str) -> RenderableType | None:
28
+ """Get the icon renderable for an agent."""
29
+ if agent not in _preview_icon_cache:
30
+ icon_path = ASSETS_DIR / f"{agent}.png"
31
+ if icon_path.exists():
32
+ try:
33
+ _preview_icon_cache[agent] = ImageRenderable(
34
+ icon_path, width=2, height=1
35
+ )
36
+ except Exception:
37
+ _preview_icon_cache[agent] = None
38
+ else:
39
+ _preview_icon_cache[agent] = None
40
+ return _preview_icon_cache[agent]
41
+
42
+
43
+ class SessionPreview(Static):
44
+ """Preview pane showing session content."""
45
+
46
+ # Highlight style for matches in preview
47
+ MATCH_STYLE = "bold reverse"
48
+ # Max lines to show for a single assistant message (None = no limit)
49
+ MAX_ASSISTANT_LINES: int | None = None
50
+
51
+ # Pattern to match code blocks with optional language
52
+ CODE_BLOCK_PATTERN = re.compile(r"```(\w*)")
53
+
54
+ def __init__(self) -> None:
55
+ super().__init__("", id="preview")
56
+
57
+ def update_preview(self, session: Session | None, query: str = "") -> None:
58
+ """Update the preview with session content, highlighting matches."""
59
+ if session is None:
60
+ self.update("")
61
+ return
62
+
63
+ result = self._build_preview_renderable(session, query)
64
+ self.update(result)
65
+
66
+ def _build_preview_renderable(
67
+ self, session: Session, query: str = ""
68
+ ) -> RenderableType:
69
+ """Build the preview renderable with icons. Returns a Group of renderables."""
70
+ content = session.content
71
+ preview_text = ""
72
+
73
+ # If there's a query, try to show the part containing the match
74
+ if query:
75
+ query_lower = query.lower()
76
+ content_lower = content.lower()
77
+ terms = query_lower.split()
78
+
79
+ # Find the first matching term
80
+ best_pos = -1
81
+ for term in terms:
82
+ if term:
83
+ pos = content_lower.find(term)
84
+ if pos != -1 and (best_pos == -1 or pos < best_pos):
85
+ best_pos = pos
86
+
87
+ if best_pos != -1:
88
+ # Show context around the match (start 200 chars before, up to 5000 chars)
89
+ start = max(0, best_pos - 200)
90
+ end = min(len(content), start + 5000)
91
+ preview_text = content[start:end]
92
+ if start > 0:
93
+ preview_text = "..." + preview_text
94
+ if end < len(content):
95
+ preview_text = preview_text + "..."
96
+
97
+ # Fall back to full content (limited) if no match found
98
+ if not preview_text:
99
+ preview_text = content[:5000]
100
+ if len(content) > 5000:
101
+ preview_text += "..."
102
+
103
+ # Get agent config and icon
104
+ agent_config = AGENTS.get(
105
+ session.agent, {"color": "white", "badge": session.agent}
106
+ )
107
+ agent_icon = _get_agent_icon(session.agent)
108
+
109
+ # Build list of renderables
110
+ renderables: list[RenderableType] = []
111
+
112
+ # Split by double newlines to get individual messages
113
+ messages = preview_text.split("\n\n")
114
+
115
+ for i, msg in enumerate(messages):
116
+ msg = msg.rstrip()
117
+ if not msg.strip():
118
+ continue
119
+
120
+ # Detect if this is a user message
121
+ is_user = msg.startswith("» ")
122
+
123
+ if is_user:
124
+ # User message - render as text
125
+ text = Text()
126
+ self._render_message(text, msg, query, is_user, agent_config)
127
+ renderables.append(text)
128
+ else:
129
+ # Assistant message - add icon + text
130
+ if agent_icon is not None:
131
+ # Create icon with text on same line using Columns
132
+ text = Text()
133
+ self._render_message_content(text, msg, query, agent_config)
134
+ renderables.append(Columns([agent_icon, text], padding=(0, 1)))
135
+ else:
136
+ # Fallback to badge
137
+ text = Text()
138
+ self._render_message(text, msg, query, is_user, agent_config)
139
+ renderables.append(text)
140
+
141
+ return Group(*renderables)
142
+
143
+ def build_preview_text(self, session: Session, query: str = "") -> Text:
144
+ """Build the preview text for a session. Returns a Rich Text object."""
145
+ content = session.content
146
+ preview_text = ""
147
+
148
+ # If there's a query, try to show the part containing the match
149
+ if query:
150
+ query_lower = query.lower()
151
+ content_lower = content.lower()
152
+ terms = query_lower.split()
153
+
154
+ # Find the first matching term
155
+ best_pos = -1
156
+ for term in terms:
157
+ if term:
158
+ pos = content_lower.find(term)
159
+ if pos != -1 and (best_pos == -1 or pos < best_pos):
160
+ best_pos = pos
161
+
162
+ if best_pos != -1:
163
+ # Show context around the match (start 200 chars before, up to 5000 chars)
164
+ start = max(0, best_pos - 200)
165
+ end = min(len(content), start + 5000)
166
+ preview_text = content[start:end]
167
+ if start > 0:
168
+ preview_text = "..." + preview_text
169
+ if end < len(content):
170
+ preview_text = preview_text + "..."
171
+
172
+ # Fall back to full content (limited) if no match found
173
+ if not preview_text:
174
+ preview_text = content[:5000]
175
+ if len(content) > 5000:
176
+ preview_text += "..."
177
+
178
+ # Build rich text with role-based styling
179
+ result = Text()
180
+
181
+ # Get agent config for styling
182
+ agent_config = AGENTS.get(
183
+ session.agent, {"color": "white", "badge": session.agent}
184
+ )
185
+
186
+ # Split by double newlines to get individual messages
187
+ messages = preview_text.split("\n\n")
188
+
189
+ for i, msg in enumerate(messages):
190
+ msg = (
191
+ msg.rstrip()
192
+ ) # Only strip trailing whitespace, preserve leading indent
193
+ if not msg.strip(): # Skip if empty after stripping
194
+ continue
195
+
196
+ # Detect if this is a user message
197
+ is_user = msg.startswith("» ")
198
+
199
+ # Process message with code block handling
200
+ self._render_message(result, msg, query, is_user, agent_config)
201
+
202
+ return result
203
+
204
+ def _render_message(
205
+ self,
206
+ result: Text,
207
+ msg: str,
208
+ query: str,
209
+ is_user: bool,
210
+ agent_config: dict[str, str],
211
+ ) -> None:
212
+ """Render a single message with code block syntax highlighting."""
213
+ lines = msg.split("\n")
214
+
215
+ # Truncate assistant messages if limit is set
216
+ if (
217
+ not is_user
218
+ and self.MAX_ASSISTANT_LINES is not None
219
+ and len(lines) > self.MAX_ASSISTANT_LINES
220
+ ):
221
+ lines = lines[: self.MAX_ASSISTANT_LINES]
222
+ lines.append("...")
223
+
224
+ # Add role prefix on first line
225
+ first_line = True
226
+
227
+ i = 0
228
+ while i < len(lines):
229
+ line = lines[i]
230
+
231
+ # Check for code block start
232
+ match = self.CODE_BLOCK_PATTERN.match(line)
233
+ if match:
234
+ language = match.group(1) or ""
235
+ # Collect code block content
236
+ code_lines = []
237
+ i += 1
238
+ while i < len(lines) and not lines[i].startswith("```"):
239
+ code_lines.append(lines[i])
240
+ i += 1
241
+
242
+ # Render code block with syntax highlighting
243
+ if code_lines:
244
+ code = "\n".join(code_lines)
245
+ self._render_code_with_highlighting(result, code, language)
246
+
247
+ # Skip closing ```
248
+ if i < len(lines) and lines[i].startswith("```"):
249
+ i += 1
250
+ continue
251
+
252
+ # Handle user prompt
253
+ if line.startswith("» "):
254
+ result.append("» ", style="bold cyan")
255
+ content_part = escape_markup(line[2:])
256
+ if len(content_part) > 200:
257
+ content_part = content_part[:200].rsplit(" ", 1)[0] + " ..."
258
+ highlighted = highlight_matches(
259
+ content_part, query, style=self.MATCH_STYLE
260
+ )
261
+ result.append_text(highlighted)
262
+ result.append("\n")
263
+ first_line = False
264
+ elif line == "...":
265
+ result.append(" ⋯\n", style="dim")
266
+ elif line.startswith("..."):
267
+ # Truncation indicator from context
268
+ result.append(escape_markup(line) + "\n", style="dim")
269
+ elif line.startswith(" "):
270
+ # Assistant response (indented) - add agent prefix on first line
271
+ if first_line:
272
+ agent_color = agent_config["color"]
273
+ result.append("● ", style=agent_color)
274
+ result.append(agent_config["badge"], style=f"bold {agent_color}")
275
+ result.append(" ")
276
+ # Remove leading indent for first line since we added prefix
277
+ content = line.lstrip()
278
+ first_line = False
279
+ else:
280
+ content = line
281
+ highlighted = highlight_matches(
282
+ escape_markup(content), query, style=self.MATCH_STYLE
283
+ )
284
+ result.append_text(highlighted)
285
+ result.append("\n")
286
+ else:
287
+ # Other content
288
+ highlighted = highlight_matches(
289
+ escape_markup(line), query, style=self.MATCH_STYLE
290
+ )
291
+ result.append_text(highlighted)
292
+ result.append("\n")
293
+
294
+ i += 1
295
+
296
+ def _render_message_content(
297
+ self,
298
+ result: Text,
299
+ msg: str,
300
+ query: str,
301
+ agent_config: dict[str, str],
302
+ ) -> None:
303
+ """Render assistant message content without badge (icon shown separately)."""
304
+ lines = msg.split("\n")
305
+
306
+ # Truncate assistant messages if limit is set
307
+ if (
308
+ self.MAX_ASSISTANT_LINES is not None
309
+ and len(lines) > self.MAX_ASSISTANT_LINES
310
+ ):
311
+ lines = lines[: self.MAX_ASSISTANT_LINES]
312
+ lines.append("...")
313
+
314
+ i = 0
315
+ while i < len(lines):
316
+ line = lines[i]
317
+
318
+ # Check for code block start
319
+ match = self.CODE_BLOCK_PATTERN.match(line)
320
+ if match:
321
+ language = match.group(1) or ""
322
+ code_lines = []
323
+ i += 1
324
+ while i < len(lines) and not lines[i].startswith("```"):
325
+ code_lines.append(lines[i])
326
+ i += 1
327
+
328
+ if code_lines:
329
+ code = "\n".join(code_lines)
330
+ self._render_code_with_highlighting(result, code, language)
331
+
332
+ if i < len(lines) and lines[i].startswith("```"):
333
+ i += 1
334
+ continue
335
+
336
+ if line == "...":
337
+ result.append("⋯\n", style="dim")
338
+ elif line.startswith("..."):
339
+ result.append(escape_markup(line) + "\n", style="dim")
340
+ elif line.startswith(" "):
341
+ # Assistant response - strip leading indent
342
+ content = line.lstrip()
343
+ highlighted = highlight_matches(
344
+ escape_markup(content), query, style=self.MATCH_STYLE
345
+ )
346
+ result.append_text(highlighted)
347
+ result.append("\n")
348
+ else:
349
+ highlighted = highlight_matches(
350
+ escape_markup(line), query, style=self.MATCH_STYLE
351
+ )
352
+ result.append_text(highlighted)
353
+ result.append("\n")
354
+
355
+ i += 1
356
+
357
+ def _render_code_with_highlighting(
358
+ self, result: Text, code: str, language: str
359
+ ) -> None:
360
+ """Render code with syntax highlighting."""
361
+ # Map common language aliases
362
+ lang_map = {
363
+ "js": "javascript",
364
+ "ts": "typescript",
365
+ "py": "python",
366
+ "rb": "ruby",
367
+ "sh": "bash",
368
+ "yml": "yaml",
369
+ "": "text",
370
+ }
371
+ language = lang_map.get(language, language) or "text"
372
+
373
+ try:
374
+ syntax = Syntax(
375
+ code,
376
+ language,
377
+ theme="ansi_dark",
378
+ line_numbers=False,
379
+ word_wrap=True,
380
+ background_color="default",
381
+ )
382
+ # Rich Syntax objects can be converted to Text
383
+ string_io = StringIO()
384
+ console = Console(file=string_io, force_terminal=True, width=200)
385
+ console.print(syntax, end="")
386
+ highlighted_code = string_io.getvalue()
387
+
388
+ # Add with indentation
389
+ for line in highlighted_code.rstrip().split("\n"):
390
+ result.append(" ")
391
+ result.append_text(Text.from_ansi(line))
392
+ result.append("\n")
393
+ except Exception:
394
+ # Fall back to plain dim text
395
+ for line in code.split("\n"):
396
+ result.append(" " + escape_markup(line) + "\n", style="dim")
@@ -0,0 +1,86 @@
1
+ """Query parsing utilities for search keyword syntax."""
2
+
3
+ import re
4
+
5
+ from ..config import AGENTS
6
+
7
+ # Pattern to match keyword:value syntax in search queries (with optional - prefix)
8
+ KEYWORD_PATTERN = re.compile(r"(-?)(agent:|dir:|date:)(\S+)")
9
+
10
+ # Valid date keywords and pattern
11
+ VALID_DATE_KEYWORDS = {"today", "yesterday", "week", "month"}
12
+ DATE_PATTERN = re.compile(r"^([<>])?(\d+)(m|h|d|w|mo|y)$")
13
+
14
+ # Pattern to match agent: keyword specifically (for extraction/replacement)
15
+ AGENT_KEYWORD_PATTERN = re.compile(r"-?agent:(\S+)")
16
+
17
+
18
+ def is_valid_filter_value(keyword: str, value: str) -> bool:
19
+ """Check if a filter value is valid for the given keyword."""
20
+ check_value = value.lstrip("!")
21
+ values = [v.strip().lstrip("!") for v in check_value.split(",") if v.strip()]
22
+
23
+ if keyword == "agent:":
24
+ return all(v.lower() in AGENTS for v in values)
25
+ elif keyword == "date:":
26
+ for v in values:
27
+ v_lower = v.lower()
28
+ if v_lower not in VALID_DATE_KEYWORDS and not DATE_PATTERN.match(v_lower):
29
+ return False
30
+ return True
31
+ elif keyword == "dir:":
32
+ return True # Any value is valid for directory
33
+ return True
34
+
35
+
36
+ def extract_agent_from_query(query: str) -> str | None:
37
+ """Extract agent value from query string if present.
38
+
39
+ Returns the first non-negated agent value, or None if no agent keyword.
40
+ For mixed filters like agent:claude,!codex, returns 'claude'.
41
+ """
42
+ match = AGENT_KEYWORD_PATTERN.search(query)
43
+ if not match:
44
+ return None
45
+
46
+ # Check if the whole keyword is negated with - prefix
47
+ full_match = match.group(0)
48
+ if full_match.startswith("-"):
49
+ return None # Negated filter, don't sync to buttons
50
+
51
+ value = match.group(1)
52
+ # Handle ! prefix on value
53
+ if value.startswith("!"):
54
+ return None # Negated, don't sync
55
+
56
+ # Get first non-negated value from comma-separated list
57
+ for v in value.split(","):
58
+ v = v.strip()
59
+ if v and not v.startswith("!"):
60
+ return v
61
+
62
+ return None
63
+
64
+
65
+ def update_agent_in_query(query: str, agent: str | None) -> str:
66
+ """Update or remove agent keyword in query string.
67
+
68
+ Args:
69
+ query: Current query string
70
+ agent: Agent to set, or None to remove agent keyword
71
+
72
+ Returns:
73
+ Updated query string with agent keyword added/updated/removed.
74
+ """
75
+ # Remove existing agent keyword(s)
76
+ query_without_agent = AGENT_KEYWORD_PATTERN.sub("", query).strip()
77
+ # Clean up extra whitespace
78
+ query_without_agent = " ".join(query_without_agent.split())
79
+
80
+ if agent is None:
81
+ return query_without_agent
82
+
83
+ # Append agent keyword at the end
84
+ if query_without_agent:
85
+ return f"{query_without_agent} agent:{agent}"
86
+ return f"agent:{agent}"