Skip to content

Commit 2b01278

Browse files
authored
Merge pull request #179 from dashscope/release/agentic-cli-2
Release/agentic cli 2
2 parents 87d36b8 + 4eb84a0 commit 2b01278

29 files changed

Lines changed: 1871 additions & 126 deletions

dashscope/acli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
from __future__ import annotations
33

4-
__version__ = "0.6.0"
4+
__version__ = "0.6.2"
55

66
# Expose the lightweight programmatic SDK at the package root.
77
try:

dashscope/acli/agent.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,32 @@ def reset(self):
235235
self.messages = []
236236

237237
def load_session(self) -> int:
238-
"""Restore self.messages from session_path. Returns the number of
239-
messages loaded (0 if no file, file empty, or parse failed)."""
240-
if not self.session_path or not self.session_path.exists():
238+
"""Restore self.messages. Returns the number of messages loaded
239+
(0 if nothing could be restored).
240+
241+
The per-topic event stream is the source of truth: the latest
242+
``messages/snapshot`` wins when present (crash recovery via
243+
append-only replay; torn trailing lines are skipped). The
244+
``history.json`` file remains as the compat fallback for
245+
sessions written before snapshots existed.
246+
"""
247+
if not self.session_path:
248+
return 0
249+
try:
250+
from dashscope.acli.session_events import (
251+
EVENTS_FILENAME,
252+
SessionEventLog,
253+
latest_snapshot_messages,
254+
)
255+
256+
log = SessionEventLog(self.session_path.parent / EVENTS_FILENAME)
257+
resumed = latest_snapshot_messages(log.read_raw())
258+
if resumed:
259+
self.messages = resumed
260+
return len(resumed)
261+
except Exception:
262+
pass
263+
if not self.session_path.exists():
241264
return 0
242265
try:
243266
data = json.loads(self.session_path.read_text())
@@ -326,11 +349,25 @@ def _system_prompt_for_turn(self, user_input_text: str) -> str:
326349
experience_tracker=self.experience_tracker,
327350
disabled_caps_provider=self.disabled_caps_provider,
328351
directives_provider=self.directives_provider,
352+
scene_provider=self._scene_section,
329353
current_turn_tools=self._current_turn_tools,
330354
connected_mcp_services=self._connected_mcp_services,
331355
)
332356
return self._prompt_pipeline.render(ctx)
333357

358+
def _scene_section(self) -> str:
359+
"""Scene memory of the current session topic (best-effort).
360+
361+
Subagents and SDK callers may run without a session manager;
362+
any failure simply yields no scene section.
363+
"""
364+
try:
365+
from dashscope.acli.session import get_session_manager
366+
367+
return get_session_manager().get_scene()
368+
except Exception:
369+
return ""
370+
334371
def _reflection_section(self) -> str:
335372
"""Inject reflection hints when repeated failures detected."""
336373
tracker = self.memory_manager.session.reflection
@@ -524,9 +561,9 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
524561
async for chunk in self.provider.chat_stream(
525562
normalize_for_model(messages_with_system, self.model_name),
526563
tools_schema,
527-
response_format={"type": "json_object"}
528-
if self.json_mode
529-
else None,
564+
response_format=(
565+
{"type": "json_object"} if self.json_mode else None
566+
),
530567
):
531568
if chunk.delta_content:
532569
full_content += chunk.delta_content
@@ -765,6 +802,39 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
765802
),
766803
)
767804

805+
# Record the completed turn as events (session-as-event-log
806+
# direction) BEFORE persisting history.json: the event stream is
807+
# the source of truth, so it must never be older than the
808+
# fallback store if we crash between the two writes. Best-effort:
809+
# subagents/SDK callers may run without a session manager, and
810+
# event recording must never break the loop.
811+
try:
812+
from dashscope.acli.session import get_session_manager
813+
814+
turn_topic = (
815+
self.session_path.parent.name if self.session_path else None
816+
)
817+
get_session_manager().record_turn_event(
818+
user_text=text_of(user_input),
819+
assistant_text=last_content,
820+
tools_used=self._current_turn_tools,
821+
outcome=(
822+
_classify_outcome(
823+
self._turn_tool_successes,
824+
self._turn_tool_failures,
825+
)
826+
if self._current_turn_tools
827+
else ""
828+
),
829+
topic=turn_topic,
830+
)
831+
get_session_manager().record_messages_snapshot(
832+
self.messages,
833+
topic=turn_topic,
834+
)
835+
except Exception:
836+
pass
837+
768838
# Persist session before memory write so a memory exception can't
769839
# cost us the conversation.
770840
self.save_session()

dashscope/acli/cli/__init__.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,15 @@
5252
_handle_slash_command,
5353
dispatch_async_command,
5454
)
55-
from dashscope.acli.cli.examples import ( # noqa: E402
56-
_handle_example_command,
57-
)
55+
from dashscope.acli.cli.examples import _handle_example_command # noqa: E402
5856
from dashscope.acli.cli.handlers_capability import ( # noqa: F401,E402
5957
_cap_enabled,
6058
sync_extensions_into_catalog,
6159
)
6260
from dashscope.acli.cli.handlers_misc import ( # noqa: F401,E402
6361
_handle_report_command,
6462
)
65-
from dashscope.acli.cli.handlers_setup import ( # noqa: F401,E402
66-
_handle_setup,
67-
)
63+
from dashscope.acli.cli.handlers_setup import _handle_setup # noqa: F401,E402
6864

6965
# Import MCP management from submodule
7066
from dashscope.acli.cli.mcp import ( # noqa: F401,E402

dashscope/acli/cli/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
"/report",
187187
"/feedback",
188188
"/history",
189+
"/undo",
189190
"/json",
190191
"/save",
191192
"/privacy",
@@ -228,7 +229,7 @@
228229
"/mcp": ["list", "add", "remove"],
229230
"/cron": ["add", "list", "remove", "pause", "resume"],
230231
"/feedback": ["good", "bad"],
231-
"/history": ["stats", "list", "export", "clear"],
232+
"/history": ["stats", "list", "search", "export", "clear"],
232233
"/json": ["on", "off"],
233234
"/privacy": ["on", "off", "status"],
234235
"/audit": ["recent", "query", "clear"],

dashscope/acli/cli/dispatch.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,11 @@ def _handle_slash_command(
403403
elif cmd.startswith("/history"):
404404
_handle_history_command(cmd)
405405
return True
406+
elif cmd == "/undo":
407+
from dashscope.acli.tools.checkpoint import handle_undo_command
408+
409+
handle_undo_command()
410+
return True
406411
elif cmd.startswith("/privacy"):
407412
_handle_privacy_command(cmd, config)
408413
return True

dashscope/acli/cli/handlers_misc.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# -*- coding: utf-8 -*-
22
"""Miscellaneous command handlers (trust, history, report)."""
33
# pylint: disable=protected-access,too-many-branches,too-many-statements
4+
# pylint: disable=too-many-return-statements
45

56
from __future__ import annotations
67

8+
from typing import Any
9+
710
from rich.console import Console
11+
from rich.markup import escape
812

913
from dashscope.acli.agent import Agent
1014

@@ -77,6 +81,86 @@ def _handle_trust_command(cmd: str, agent: Agent) -> None:
7781
)
7882

7983

84+
def _message_text(msg: dict[str, Any]) -> str:
85+
"""Flatten a chat message's content into one-line plain text."""
86+
content = msg.get("content", "")
87+
if isinstance(content, list):
88+
content = " ".join(
89+
part.get("text", "") for part in content if isinstance(part, dict)
90+
)
91+
if not isinstance(content, str):
92+
return ""
93+
return " ".join(content.split())
94+
95+
96+
def _search_snippet(text: str, idx: int, kw_len: int) -> str:
97+
"""Build a short one-line snippet centered on a match position."""
98+
start = max(0, idx - 20)
99+
end = min(len(text), idx + kw_len + 40)
100+
prefix = "..." if start > 0 else ""
101+
suffix = "..." if end < len(text) else ""
102+
return prefix + text[start:end] + suffix
103+
104+
105+
def _highlight_keyword(text: str, keyword: str) -> str:
106+
"""Wrap keyword occurrences in rich markup (case-insensitive)."""
107+
lower_kw = keyword.lower()
108+
if not lower_kw:
109+
return escape(text)
110+
lower_text = text.lower()
111+
out: list[str] = []
112+
pos = 0
113+
while True:
114+
idx = lower_text.find(lower_kw, pos)
115+
if idx < 0:
116+
out.append(escape(text[pos:]))
117+
break
118+
out.append(escape(text[pos:idx]))
119+
out.append("[bold yellow]")
120+
out.append(escape(text[idx : idx + len(keyword)]))
121+
out.append("[/bold yellow]")
122+
pos = idx + len(keyword)
123+
return "".join(out)
124+
125+
126+
def _history_search_matches(
127+
keyword: str,
128+
limit: int = 20,
129+
) -> list[dict[str, str]]:
130+
"""Case-insensitive substring search across all session history.
131+
132+
Scans every stored session's messages and returns up to ``limit``
133+
matches, each carrying the session topic, a timestamp, the message
134+
role, and a one-line snippet with match context.
135+
"""
136+
from dashscope.acli.session import get_session_manager
137+
138+
needle = keyword.lower()
139+
if not needle or limit <= 0:
140+
return []
141+
mgr = get_session_manager()
142+
matches: list[dict[str, str]] = []
143+
for meta in mgr.list_topics():
144+
if len(matches) >= limit:
145+
break
146+
for msg in mgr.load_messages(meta.topic):
147+
text = _message_text(msg)
148+
idx = text.lower().find(needle) if text else -1
149+
if idx < 0:
150+
continue
151+
matches.append(
152+
{
153+
"session": meta.topic,
154+
"timestamp": meta.last_accessed or "",
155+
"role": str(msg.get("role", "?")),
156+
"snippet": _search_snippet(text, idx, len(needle)),
157+
},
158+
)
159+
if len(matches) >= limit:
160+
break
161+
return matches
162+
163+
80164
def _handle_history_command(cmd: str) -> None:
81165
"""Manage conversation history."""
82166
from dashscope.acli.platforms.local.history import (
@@ -107,6 +191,8 @@ def _handle_history_command(cmd: str) -> None:
107191
"\n[dim]Usage:\n"
108192
" /history stats — show stats\n"
109193
" /history list [n] — list recent n\n"
194+
" /history search <keyword> [limit] — full-text "
195+
"search\n"
110196
" /history export <file> [--format json|md|html] — export\n"
111197
" /history clear — clear "
112198
"history[/dim]",
@@ -147,6 +233,41 @@ def _handle_history_command(cmd: str) -> None:
147233
)
148234
return
149235

236+
if sub == "search":
237+
if len(parts) < 3:
238+
console.print(
239+
"[dim]Usage: /history search <keyword> [limit][/dim]",
240+
)
241+
return
242+
keyword = parts[2]
243+
limit = 20
244+
if len(parts) >= 4:
245+
try:
246+
limit = int(parts[3])
247+
except ValueError:
248+
limit = 0
249+
if limit <= 0:
250+
console.print(
251+
"[red]limit must be a positive integer[/red]",
252+
)
253+
return
254+
matches = _history_search_matches(keyword, limit=limit)
255+
if not matches:
256+
console.print(f"[dim]No matches for '{keyword}'[/dim]")
257+
return
258+
header = f"[bold]{len(matches)} match(es) for '{keyword}':[/bold]"
259+
console.print(header)
260+
for i, m in enumerate(matches, 1):
261+
ts = m["timestamp"][:16]
262+
head = (
263+
f" {i}. [cyan]{m['session']}[/cyan] "
264+
f"[dim]{ts}[/dim] [bold]{m['role']}[/bold]"
265+
)
266+
console.print(head)
267+
snippet = _highlight_keyword(m["snippet"], keyword)
268+
console.print(f" {snippet}")
269+
return
270+
150271
if sub == "export" and len(parts) >= 3:
151272
output_path = parts[2]
152273
fmt = "html"
@@ -174,7 +295,8 @@ def _handle_history_command(cmd: str) -> None:
174295
console.print(f"[green]✓ Cleared {count} history records[/green]")
175296
return
176297

177-
console.print("[dim]Usage: /history [stats|list|export|clear][/dim]")
298+
usage = "[dim]Usage: /history [stats|list|search|export|clear][/dim]"
299+
console.print(usage)
178300

179301

180302
def _handle_report_command(agent: Agent) -> None:

0 commit comments

Comments
 (0)