fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.16__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.

Potentially problematic release.


This version of fast-agent-mcp might be problematic. Click here for more details.

Files changed (39) hide show
  1. fast_agent/__init__.py +2 -0
  2. fast_agent/agents/agent_types.py +5 -0
  3. fast_agent/agents/llm_agent.py +7 -0
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +134 -10
  6. fast_agent/cli/__main__.py +35 -0
  7. fast_agent/cli/commands/check_config.py +85 -0
  8. fast_agent/cli/commands/go.py +100 -36
  9. fast_agent/cli/constants.py +13 -1
  10. fast_agent/cli/main.py +1 -0
  11. fast_agent/config.py +39 -10
  12. fast_agent/constants.py +8 -0
  13. fast_agent/context.py +24 -15
  14. fast_agent/core/direct_decorators.py +9 -0
  15. fast_agent/core/fastagent.py +101 -1
  16. fast_agent/core/logging/listeners.py +8 -0
  17. fast_agent/interfaces.py +8 -0
  18. fast_agent/llm/fastagent_llm.py +45 -0
  19. fast_agent/llm/memory.py +26 -1
  20. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  21. fast_agent/llm/provider/openai/llm_openai.py +184 -18
  22. fast_agent/llm/provider/openai/responses.py +133 -0
  23. fast_agent/resources/setup/agent.py +2 -0
  24. fast_agent/resources/setup/fastagent.config.yaml +6 -0
  25. fast_agent/skills/__init__.py +9 -0
  26. fast_agent/skills/registry.py +200 -0
  27. fast_agent/tools/shell_runtime.py +404 -0
  28. fast_agent/ui/console_display.py +396 -129
  29. fast_agent/ui/elicitation_form.py +76 -24
  30. fast_agent/ui/elicitation_style.py +2 -2
  31. fast_agent/ui/enhanced_prompt.py +81 -25
  32. fast_agent/ui/history_display.py +20 -5
  33. fast_agent/ui/interactive_prompt.py +108 -3
  34. fast_agent/ui/markdown_truncator.py +1 -1
  35. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/METADATA +8 -7
  36. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/RECORD +39 -35
  37. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/WHEEL +0 -0
  38. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/entry_points.txt +0 -0
  39. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/licenses/LICENSE +0 -0
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional
7
7
  from mcp.types import ElicitRequestedSchema
8
8
  from prompt_toolkit import Application
9
9
  from prompt_toolkit.buffer import Buffer
10
+ from prompt_toolkit.filters import Condition
10
11
  from prompt_toolkit.formatted_text import FormattedText
11
12
  from prompt_toolkit.key_binding import KeyBindings
12
13
  from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
@@ -25,6 +26,8 @@ from pydantic import ValidationError as PydanticValidationError
25
26
 
26
27
  from fast_agent.ui.elicitation_style import ELICITATION_STYLE
27
28
 
29
+ text_navigation_mode = False
30
+
28
31
 
29
32
  class SimpleNumberValidator(Validator):
30
33
  """Simple number validator with real-time feedback."""
@@ -68,7 +71,7 @@ class SimpleStringValidator(Validator):
68
71
  self,
69
72
  min_length: Optional[int] = None,
70
73
  max_length: Optional[int] = None,
71
- pattern: Optional[str] = None
74
+ pattern: Optional[str] = None,
72
75
  ):
73
76
  self.min_length = min_length
74
77
  self.max_length = max_length
@@ -314,6 +317,10 @@ class ElicitationForm:
314
317
  ]
315
318
  )
316
319
 
320
+ # Use field navigation mode as default
321
+ global text_navigation_mode
322
+ text_navigation_mode = False
323
+
317
324
  # Key bindings
318
325
  kb = KeyBindings()
319
326
 
@@ -325,31 +332,49 @@ class ElicitationForm:
325
332
  def focus_previous_with_refresh(event):
326
333
  focus_previous(event)
327
334
 
335
+ # Toggle between text navigation mode and field navigation mode
336
+ @kb.add("c-t")
337
+ def toggle_text_navigation_mode(event):
338
+ global text_navigation_mode
339
+ text_navigation_mode = not text_navigation_mode
340
+ event.app.invalidate() # Force redraw the app to update toolbar
341
+
328
342
  # Arrow key navigation - let radio lists handle up/down first
329
- @kb.add("down")
343
+ @kb.add("down", filter=Condition(lambda: not text_navigation_mode))
330
344
  def focus_next_arrow(event):
331
345
  focus_next(event)
332
346
 
333
- @kb.add("up")
347
+ @kb.add("up", filter=Condition(lambda: not text_navigation_mode))
334
348
  def focus_previous_arrow(event):
335
349
  focus_previous(event)
336
350
 
337
- @kb.add("right", eager=True)
351
+ @kb.add("right", eager=True, filter=Condition(lambda: not text_navigation_mode))
338
352
  def focus_next_right(event):
339
353
  focus_next(event)
340
354
 
341
- @kb.add("left", eager=True)
355
+ @kb.add("left", eager=True, filter=Condition(lambda: not text_navigation_mode))
342
356
  def focus_previous_left(event):
343
357
  focus_previous(event)
344
358
 
345
- # Enter always submits
346
- @kb.add("c-m")
347
- def submit(event):
359
+ # Enter submits in field navigation mode
360
+ @kb.add("c-m", filter=Condition(lambda: not text_navigation_mode))
361
+ def submit_enter(event):
348
362
  self._accept()
349
363
 
350
- # Ctrl+J inserts newlines
351
- @kb.add("c-j")
352
- def insert_newline(event):
364
+ # Ctrl+J inserts newlines in field navigation mode
365
+ @kb.add("c-j", filter=Condition(lambda: not text_navigation_mode))
366
+ def insert_newline_cj(event):
367
+ # Insert a newline at the cursor position
368
+ event.current_buffer.insert_text("\n")
369
+ # Mark this field as multiline when user adds a newline
370
+ for field_name, widget in self.field_widgets.items():
371
+ if isinstance(widget, Buffer) and widget == event.current_buffer:
372
+ self.multiline_fields.add(field_name)
373
+ break
374
+
375
+ # Enter inserts new lines in text navigation mode
376
+ @kb.add("c-m", filter=Condition(lambda: text_navigation_mode))
377
+ def insert_newline_enter(event):
353
378
  # Insert a newline at the cursor position
354
379
  event.current_buffer.insert_text("\n")
355
380
  # Mark this field as multiline when user adds a newline
@@ -358,6 +383,11 @@ class ElicitationForm:
358
383
  self.multiline_fields.add(field_name)
359
384
  break
360
385
 
386
+ # deactivate ctrl+j in text navigation mode
387
+ @kb.add("c-j", filter=Condition(lambda: text_navigation_mode))
388
+ def _(event):
389
+ pass
390
+
361
391
  # ESC should ALWAYS cancel immediately, no matter what
362
392
  @kb.add("escape", eager=True, is_global=True)
363
393
  def cancel(event):
@@ -369,18 +399,40 @@ class ElicitationForm:
369
399
  if hasattr(self, "_toolbar_hidden") and self._toolbar_hidden:
370
400
  return FormattedText([])
371
401
 
372
- return FormattedText(
373
- [
374
- (
375
- "class:bottom-toolbar.text",
376
- " <TAB>/↑↓→← navigate. <ENTER> submit. <Ctrl+J> insert new line. <ESC> cancel. ",
377
- ),
378
- (
379
- "class:bottom-toolbar.text",
380
- "<Cancel All> Auto-Cancel further elicitations from this Server.",
381
- ),
382
- ]
383
- )
402
+ mode_label = "TEXT MODE" if text_navigation_mode else "FIELD MODE"
403
+ mode_color = "ansired" if text_navigation_mode else "ansigreen"
404
+
405
+ arrow_up = ""
406
+ arrow_down = "↓"
407
+ arrow_left = "←"
408
+ arrow_right = "→"
409
+
410
+ if text_navigation_mode:
411
+ actions_line = (
412
+ " <ESC> cancel. <Cancel All> Auto-Cancel further elicitations from this Server."
413
+ )
414
+ navigation_tail = (
415
+ " | <CTRL+T> toggle text mode. <TAB> navigate. <ENTER> insert new line."
416
+ )
417
+ else:
418
+ actions_line = (
419
+ " <ENTER> submit. <ESC> cancel. <Cancel All> Auto-Cancel further elicitations "
420
+ "from this Server."
421
+ )
422
+ navigation_tail = (
423
+ " | <CTRL+T> toggle text mode. "
424
+ f"<TAB>/{arrow_up}{arrow_down}{arrow_right}{arrow_left} navigate. "
425
+ "<Ctrl+J> insert new line."
426
+ )
427
+
428
+ formatted_segments = [
429
+ ("class:bottom-toolbar.text", actions_line),
430
+ ("", "\n"),
431
+ ("class:bottom-toolbar.text", " | "),
432
+ (f"fg:{mode_color} bg:ansiblack", f" {mode_label} "),
433
+ ("class:bottom-toolbar.text", navigation_tail),
434
+ ]
435
+ return FormattedText(formatted_segments)
384
436
 
385
437
  # Store toolbar function reference for later control
386
438
  self._get_toolbar = get_toolbar
@@ -388,7 +440,7 @@ class ElicitationForm:
388
440
 
389
441
  # Create toolbar window that we can reference later
390
442
  self._toolbar_window = Window(
391
- FormattedTextControl(get_toolbar), height=1, style="class:bottom-toolbar"
443
+ FormattedTextControl(get_toolbar), height=2, style="class:bottom-toolbar"
392
444
  )
393
445
 
394
446
  # Add toolbar to the layout
@@ -53,7 +53,7 @@ ELICITATION_STYLE = Style.from_dict(
53
53
  "completion-menu.meta.completion": "bg:ansiblack fg:ansiblue",
54
54
  "completion-menu.meta.completion.current": "bg:ansibrightblack fg:ansiblue",
55
55
  # Toolbar - matching enhanced_prompt.py exactly
56
- "bottom-toolbar": "fg:ansiblack bg:ansigray",
57
- "bottom-toolbar.text": "fg:ansiblack bg:ansigray",
56
+ "bottom-toolbar": "fg:#ansiblack bg:#ansigray",
57
+ "bottom-toolbar.text": "fg:#ansiblack bg:#ansigray",
58
58
  }
59
59
  )
@@ -121,6 +121,14 @@ async def _display_agent_info_helper(agent_name: str, agent_provider: "AgentApp
121
121
  prompts_dict = await agent.list_prompts()
122
122
  prompt_count = sum(len(prompts) for prompts in prompts_dict.values()) if prompts_dict else 0
123
123
 
124
+ skill_count = 0
125
+ skill_manifests = getattr(agent, "_skill_manifests", None)
126
+ if skill_manifests:
127
+ try:
128
+ skill_count = len(list(skill_manifests))
129
+ except TypeError:
130
+ skill_count = 0
131
+
124
132
  # Handle different agent types
125
133
  if agent.agent_type == AgentType.PARALLEL:
126
134
  # Count child agents for parallel agents
@@ -149,36 +157,38 @@ async def _display_agent_info_helper(agent_name: str, agent_provider: "AgentApp
149
157
  f"[dim]Agent [/dim][blue]{agent_name}[/blue][dim]:[/dim] {child_count:,}[dim] {child_word}[/dim]"
150
158
  )
151
159
  else:
152
- # For regular agents, only display if they have MCP servers attached
153
- if server_count > 0:
154
- # Build display parts in order: tools, prompts, resources (omit if count is 0)
155
- display_parts = []
160
+ content_parts = []
156
161
 
162
+ if server_count > 0:
163
+ sub_parts = []
157
164
  if tool_count > 0:
158
165
  tool_word = "tool" if tool_count == 1 else "tools"
159
- display_parts.append(f"{tool_count:,}[dim] {tool_word}[/dim]")
160
-
166
+ sub_parts.append(f"{tool_count:,}[dim] {tool_word}[/dim]")
161
167
  if prompt_count > 0:
162
168
  prompt_word = "prompt" if prompt_count == 1 else "prompts"
163
- display_parts.append(f"{prompt_count:,}[dim] {prompt_word}[/dim]")
164
-
169
+ sub_parts.append(f"{prompt_count:,}[dim] {prompt_word}[/dim]")
165
170
  if resource_count > 0:
166
171
  resource_word = "resource" if resource_count == 1 else "resources"
167
- display_parts.append(f"{resource_count:,}[dim] {resource_word}[/dim]")
172
+ sub_parts.append(f"{resource_count:,}[dim] {resource_word}[/dim]")
168
173
 
169
- # Always show server count
170
174
  server_word = "Server" if server_count == 1 else "Servers"
171
175
  server_text = f"{server_count:,}[dim] MCP {server_word}[/dim]"
172
-
173
- if display_parts:
174
- content = (
175
- f"{server_text}[dim], [/dim]"
176
- + "[dim], [/dim]".join(display_parts)
177
- + "[dim] available[/dim]"
176
+ if sub_parts:
177
+ server_text = (
178
+ f"{server_text}[dim] ([/dim]"
179
+ + "[dim], [/dim]".join(sub_parts)
180
+ + "[dim])[/dim]"
178
181
  )
179
- else:
180
- content = f"{server_text}[dim] available[/dim]"
182
+ content_parts.append(server_text)
181
183
 
184
+ if skill_count > 0:
185
+ skill_word = "skill" if skill_count == 1 else "skills"
186
+ content_parts.append(
187
+ f"{skill_count:,}[dim] {skill_word}[/dim][dim] available[/dim]"
188
+ )
189
+
190
+ if content_parts:
191
+ content = "[dim]. [/dim]".join(content_parts)
182
192
  rich_print(f"[dim]Agent [/dim][blue]{agent_name}[/blue][dim]:[/dim] {content}")
183
193
  # await _render_mcp_status(agent)
184
194
 
@@ -351,9 +361,11 @@ class AgentCompleter(Completer):
351
361
  self.commands = {
352
362
  "mcp": "Show MCP server status",
353
363
  "history": "Show conversation history overview (optionally another agent)",
354
- "tools": "List available MCP tools",
364
+ "tools": "List available MCP Tools",
365
+ "skills": "List available Agent Skills",
355
366
  "prompt": "List and choose MCP prompts, or apply specific prompt (/prompt <name>)",
356
367
  "clear": "Clear history",
368
+ "clear last": "Remove the most recent message from history",
357
369
  "agents": "List available agents",
358
370
  "system": "Show the current system prompt",
359
371
  "usage": "Show current usage statistics",
@@ -854,8 +866,34 @@ async def get_enhanced_input(
854
866
  )
855
867
  session.app.key_bindings = bindings
856
868
 
869
+ shell_agent = None
870
+ shell_enabled = False
871
+ shell_access_modes: tuple[str, ...] = ()
872
+ shell_name: str | None = None
873
+ if agent_provider:
874
+ try:
875
+ shell_agent = agent_provider._agent(agent_name)
876
+ except Exception:
877
+ shell_agent = None
878
+
879
+ if shell_agent:
880
+ shell_enabled = bool(getattr(shell_agent, "_shell_runtime_enabled", False))
881
+ modes_attr = getattr(shell_agent, "_shell_access_modes", ())
882
+ if isinstance(modes_attr, (list, tuple)):
883
+ shell_access_modes = tuple(str(mode) for mode in modes_attr)
884
+ elif modes_attr:
885
+ shell_access_modes = (str(modes_attr),)
886
+
887
+ # Get the detected shell name from the runtime
888
+ if shell_enabled:
889
+ shell_runtime = getattr(shell_agent, "_shell_runtime", None)
890
+ if shell_runtime:
891
+ runtime_info = shell_runtime.runtime_info()
892
+ shell_name = runtime_info.get("name")
893
+
857
894
  # Create formatted prompt text
858
- prompt_text = f"<ansibrightblue>{agent_name}</ansibrightblue> "
895
+ arrow_segment = "<ansibrightyellow>❯</ansibrightyellow>" if shell_enabled else "❯"
896
+ prompt_text = f"<ansibrightblue>{agent_name}</ansibrightblue> {arrow_segment} "
859
897
 
860
898
  # Add default value display if requested
861
899
  if show_default and default and default != "STOP":
@@ -887,8 +925,10 @@ async def get_enhanced_input(
887
925
  # Get logger settings from the agent's context (not agent_provider)
888
926
  logger_settings = None
889
927
  try:
890
- agent = agent_provider._agent(agent_name)
891
- agent_context = agent._context or agent.context
928
+ active_agent = shell_agent
929
+ if active_agent is None:
930
+ active_agent = agent_provider._agent(agent_name)
931
+ agent_context = active_agent._context or active_agent.context
892
932
  logger_settings = agent_context.config.logger
893
933
  except Exception:
894
934
  # If we can't get the agent or its context, logger_settings stays None
@@ -922,6 +962,11 @@ async def get_enhanced_input(
922
962
  f"[dim]Experimental: Streaming Enabled - {streaming_mode} mode[/dim]"
923
963
  )
924
964
 
965
+ if shell_enabled:
966
+ modes_display = ", ".join(shell_access_modes or ("direct",))
967
+ shell_display = f"{modes_display}, {shell_name}" if shell_name else modes_display
968
+ rich_print(f"[yellow]Shell Access ({shell_display})[/yellow]")
969
+
925
970
  rich_print()
926
971
  help_message_shown = True
927
972
 
@@ -953,9 +998,16 @@ async def get_enhanced_input(
953
998
  elif cmd == "clear":
954
999
  target_agent = None
955
1000
  if len(cmd_parts) > 1:
956
- candidate = cmd_parts[1].strip()
957
- if candidate:
958
- target_agent = candidate
1001
+ remainder = cmd_parts[1].strip()
1002
+ if remainder:
1003
+ tokens = remainder.split(maxsplit=1)
1004
+ if tokens and tokens[0].lower() == "last":
1005
+ if len(tokens) > 1:
1006
+ candidate = tokens[1].strip()
1007
+ if candidate:
1008
+ target_agent = candidate
1009
+ return {"clear_last": {"agent": target_agent}}
1010
+ target_agent = remainder
959
1011
  return {"clear_history": {"agent": target_agent}}
960
1012
  elif cmd == "markdown":
961
1013
  return "MARKDOWN"
@@ -984,6 +1036,8 @@ async def get_enhanced_input(
984
1036
  elif cmd == "tools":
985
1037
  # Return a dictionary with list_tools action
986
1038
  return {"list_tools": True}
1039
+ elif cmd == "skills":
1040
+ return {"list_skills": True}
987
1041
  elif cmd == "exit":
988
1042
  return "EXIT"
989
1043
  elif cmd.lower() == "stop":
@@ -1147,8 +1201,10 @@ async def handle_special_commands(
1147
1201
  rich_print(" /system - Show the current system prompt")
1148
1202
  rich_print(" /prompt <name> - Apply a specific prompt by name")
1149
1203
  rich_print(" /usage - Show current usage statistics")
1204
+ rich_print(" /skills - List local skills for the active agent")
1150
1205
  rich_print(" /history [agent_name] - Show chat history overview")
1151
1206
  rich_print(" /clear [agent_name] - Clear conversation history (keeps templates)")
1207
+ rich_print(" /clear last [agent_name] - Remove the most recent message from history")
1152
1208
  rich_print(" /markdown - Show last assistant message without markdown formatting")
1153
1209
  rich_print(" /mcpstatus - Show MCP server status summary for the active agent")
1154
1210
  rich_print(" /save_history <filename> - Save current chat history to a file")
@@ -35,6 +35,7 @@ class Colours:
35
35
  USER = "blue"
36
36
  ASSISTANT = "green"
37
37
  TOOL = "magenta"
38
+ TOOL_ERROR = "red"
38
39
  HEADER = USER
39
40
  TIMELINE_EMPTY = "dim default"
40
41
  CONTEXT_SAFE = "green"
@@ -249,6 +250,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
249
250
  result_rows: list[dict] = []
250
251
  tool_result_total_chars = 0
251
252
  tool_result_has_non_text = False
253
+ tool_result_has_error = False
252
254
 
253
255
  if tool_calls:
254
256
  names: list[str] = []
@@ -273,6 +275,8 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
273
275
  tool_result_total_chars += result_chars
274
276
  tool_result_has_non_text = tool_result_has_non_text or result_non_text
275
277
  detail = _format_tool_detail("result→", [tool_name])
278
+ is_error = getattr(result, "isError", False)
279
+ tool_result_has_error = tool_result_has_error or is_error
276
280
  result_rows.append(
277
281
  {
278
282
  "role": "tool",
@@ -284,6 +288,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
284
288
  "has_tool_request": False,
285
289
  "hide_summary": False,
286
290
  "include_in_timeline": False,
291
+ "is_error": is_error,
287
292
  }
288
293
  )
289
294
  if role == "user":
@@ -308,6 +313,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
308
313
  if timeline_role == "tool" and tool_result_total_chars > 0:
309
314
  row_chars = tool_result_total_chars
310
315
  row_non_text = row_non_text or tool_result_has_non_text
316
+ row_is_error = tool_result_has_error
311
317
 
312
318
  rows.append(
313
319
  {
@@ -320,6 +326,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
320
326
  "has_tool_request": has_tool_request,
321
327
  "hide_summary": hide_in_summary,
322
328
  "include_in_timeline": include_in_timeline,
329
+ "is_error": row_is_error,
323
330
  }
324
331
  )
325
332
  rows.extend(result_rows)
@@ -333,12 +340,23 @@ def _aggregate_timeline_entries(rows: Sequence[dict]) -> list[dict]:
333
340
  "role": row.get("timeline_role", row["role"]),
334
341
  "chars": row["chars"],
335
342
  "non_text": row["non_text"],
343
+ "is_error": row.get("is_error", False),
336
344
  }
337
345
  for row in rows
338
346
  if row.get("include_in_timeline", True)
339
347
  ]
340
348
 
341
349
 
350
+ def _get_role_color(role: str, *, is_error: bool = False) -> str:
351
+ """Get the display color for a role, accounting for error states."""
352
+ color_map = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
353
+
354
+ if role == "tool" and is_error:
355
+ return Colours.TOOL_ERROR
356
+
357
+ return color_map.get(role, "white")
358
+
359
+
342
360
  def _shade_block(chars: int, *, non_text: bool, color: str) -> Text:
343
361
  if non_text:
344
362
  return Text(NON_TEXT_MARKER, style=f"bold {color}")
@@ -356,12 +374,10 @@ def _shade_block(chars: int, *, non_text: bool, color: str) -> Text:
356
374
 
357
375
 
358
376
  def _build_history_bar(entries: Sequence[dict], width: int = TIMELINE_WIDTH) -> tuple[Text, Text]:
359
- color_map = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
360
-
361
377
  recent = list(entries[-width:])
362
378
  bar = Text(" history |", style="dim")
363
379
  for entry in recent:
364
- color = color_map.get(entry["role"], "ansiwhite")
380
+ color = _get_role_color(entry["role"], is_error=entry.get("is_error", False))
365
381
  bar.append_text(
366
382
  _shade_block(entry["chars"], non_text=entry.get("non_text", False), color=color)
367
383
  )
@@ -507,7 +523,6 @@ def display_history_overview(
507
523
  start_index = len(summary_candidates) - len(summary_rows) + 1
508
524
 
509
525
  role_arrows = {"user": "▶", "assistant": "◀", "tool": "▶"}
510
- role_styles = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
511
526
  role_labels = {"user": "user", "assistant": "assistant", "tool": "tool result"}
512
527
 
513
528
  try:
@@ -517,7 +532,7 @@ def display_history_overview(
517
532
 
518
533
  for offset, row in enumerate(summary_rows):
519
534
  role = row["role"]
520
- color = role_styles.get(role, "white")
535
+ color = _get_role_color(role, is_error=row.get("is_error", False))
521
536
  arrow = role_arrows.get(role, "▶")
522
537
  label = role_labels.get(role, role)
523
538
  if role == "assistant" and row.get("has_tool_request"):
@@ -14,6 +14,7 @@ Usage:
14
14
  )
15
15
  """
16
16
 
17
+ from pathlib import Path
17
18
  from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union, cast
18
19
 
19
20
  if TYPE_CHECKING:
@@ -169,6 +170,9 @@ class InteractivePrompt:
169
170
  # Handle tools list display
170
171
  await self._list_tools(prompt_provider, agent)
171
172
  continue
173
+ elif "list_skills" in command_dict:
174
+ await self._list_skills(prompt_provider, agent)
175
+ continue
172
176
  elif "show_usage" in command_dict:
173
177
  # Handle usage display
174
178
  await self._show_usage(prompt_provider, agent)
@@ -189,6 +193,41 @@ class InteractivePrompt:
189
193
  usage = getattr(agent_obj, "usage_accumulator", None)
190
194
  display_history_overview(target_agent, history, usage)
191
195
  continue
196
+ elif "clear_last" in command_dict:
197
+ clear_info = command_dict.get("clear_last")
198
+ clear_agent = (
199
+ clear_info.get("agent") if isinstance(clear_info, dict) else None
200
+ )
201
+ target_agent = clear_agent or agent
202
+ try:
203
+ agent_obj = prompt_provider._agent(target_agent)
204
+ except Exception:
205
+ rich_print(f"[red]Unable to load agent '{target_agent}'[/red]")
206
+ continue
207
+
208
+ removed_message = None
209
+ pop_callable = getattr(agent_obj, "pop_last_message", None)
210
+ if callable(pop_callable):
211
+ removed_message = pop_callable()
212
+ else:
213
+ history = getattr(agent_obj, "message_history", [])
214
+ if history:
215
+ try:
216
+ removed_message = history.pop()
217
+ except Exception:
218
+ removed_message = None
219
+
220
+ if removed_message:
221
+ role = getattr(removed_message, "role", "message")
222
+ role_display = role.capitalize() if isinstance(role, str) else "Message"
223
+ rich_print(
224
+ f"[green]Removed last {role_display} for agent '{target_agent}'.[/green]"
225
+ )
226
+ else:
227
+ rich_print(
228
+ f"[yellow]No messages to remove for agent '{target_agent}'.[/yellow]"
229
+ )
230
+ continue
192
231
  elif "clear_history" in command_dict:
193
232
  clear_info = command_dict.get("clear_history")
194
233
  clear_agent = (
@@ -857,19 +896,21 @@ class InteractivePrompt:
857
896
  rich_print()
858
897
 
859
898
  # Display tools using clean compact format
860
- for i, tool in enumerate(tools_result.tools, 1):
899
+ index = 1
900
+ for tool in tools_result.tools:
861
901
  # Main line: [ 1] tool_name Title
862
902
  from rich.text import Text
863
903
 
904
+ meta = getattr(tool, "meta", {}) or {}
905
+
864
906
  tool_line = Text()
865
- tool_line.append(f"[{i:2}] ", style="dim cyan")
907
+ tool_line.append(f"[{index:2}] ", style="dim cyan")
866
908
  tool_line.append(tool.name, style="bright_blue bold")
867
909
 
868
910
  # Add title if available
869
911
  if tool.title and tool.title.strip():
870
912
  tool_line.append(f" {tool.title}", style="default")
871
913
 
872
- meta = getattr(tool, "meta", {}) or {}
873
914
  if meta.get("openai/skybridgeEnabled"):
874
915
  tool_line.append(" (skybridge)", style="cyan")
875
916
 
@@ -932,13 +973,77 @@ class InteractivePrompt:
932
973
  rich_print(f" [dim magenta]template:[/dim magenta] {template}")
933
974
 
934
975
  rich_print() # Space between tools
976
+ index += 1
935
977
 
978
+ if index == 1:
979
+ rich_print("[yellow]No MCP tools available for this agent[/yellow]")
936
980
  except Exception as e:
937
981
  import traceback
938
982
 
939
983
  rich_print(f"[red]Error listing tools: {e}[/red]")
940
984
  rich_print(f"[dim]{traceback.format_exc()}[/dim]")
941
985
 
986
+ async def _list_skills(self, prompt_provider: "AgentApp", agent_name: str) -> None:
987
+ """List available local skills for an agent."""
988
+
989
+ try:
990
+ assert hasattr(prompt_provider, "_agent"), (
991
+ "Interactive prompt expects an AgentApp with _agent()"
992
+ )
993
+ agent = prompt_provider._agent(agent_name)
994
+
995
+ rich_print(f"\n[bold]Skills for agent [cyan]{agent_name}[/cyan]:[/bold]")
996
+
997
+ skill_manifests = getattr(agent, "_skill_manifests", None)
998
+ manifests = list(skill_manifests) if skill_manifests else []
999
+
1000
+ if not manifests:
1001
+ rich_print("[yellow]No skills available for this agent[/yellow]")
1002
+ return
1003
+
1004
+ rich_print()
1005
+
1006
+ for index, manifest in enumerate(manifests, 1):
1007
+ from rich.text import Text
1008
+
1009
+ name = getattr(manifest, "name", "")
1010
+ description = getattr(manifest, "description", "")
1011
+ path = Path(getattr(manifest, "path", Path()))
1012
+
1013
+ tool_line = Text()
1014
+ tool_line.append(f"[{index:2}] ", style="dim cyan")
1015
+ tool_line.append(name, style="bright_blue bold")
1016
+ rich_print(tool_line)
1017
+
1018
+ if description:
1019
+ import textwrap
1020
+
1021
+ wrapped_lines = textwrap.wrap(
1022
+ description.strip(), width=72, subsequent_indent=" "
1023
+ )
1024
+ for line in wrapped_lines:
1025
+ if line.startswith(" "):
1026
+ rich_print(f" [white]{line[5:]}[/white]")
1027
+ else:
1028
+ rich_print(f" [white]{line}[/white]")
1029
+
1030
+ source_path = path if path else Path(".")
1031
+ if source_path.is_file():
1032
+ source_path = source_path.parent
1033
+ try:
1034
+ display_path = source_path.relative_to(Path.cwd())
1035
+ except ValueError:
1036
+ display_path = source_path
1037
+
1038
+ rich_print(f" [dim green]source:[/dim green] {display_path}")
1039
+ rich_print()
1040
+
1041
+ except Exception as exc: # noqa: BLE001
1042
+ import traceback
1043
+
1044
+ rich_print(f"[red]Error listing skills: {exc}[/red]")
1045
+ rich_print(f"[dim]{traceback.format_exc()}[/dim]")
1046
+
942
1047
  async def _show_usage(self, prompt_provider: "AgentApp", agent_name: str) -> None:
943
1048
  """
944
1049
  Show usage statistics for the current agent(s) in a colorful table format.
@@ -876,7 +876,7 @@ class MarkdownTruncator:
876
876
  return truncated_text
877
877
 
878
878
  # Find where the truncated text starts in the original
879
- truncation_pos = original_text.find(truncated_text)
879
+ truncation_pos = original_text.rfind(truncated_text)
880
880
  if truncation_pos == -1:
881
881
  # Can't find it, return as-is
882
882
  return truncated_text