agentforge-chat 0.2.2__tar.gz → 0.2.4__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.
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/PKG-INFO +7 -7
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/README.md +3 -2
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/pyproject.toml +9 -6
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/build.py +3 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/history.py +9 -1
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/manifest.yaml +1 -1
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/session.py +118 -2
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/sqlite.py +14 -2
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_session.py +108 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/.gitignore +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/LICENSE +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/__init__.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_idempotency.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_locks.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_segment.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/_window.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/py.typed +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/tokenisers.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/src/agentforge_chat/truncation.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_chat_build.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_chat_streaming_per_token.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_in_memory_history.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_sentence_window.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_session_safety_modes.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_sqlite_history.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_token_budget_tokeniser.py +0 -0
- {agentforge_chat-0.2.2 → agentforge_chat-0.2.4}/tests/unit/test_truncation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentforge-chat
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Chat-agent runtime (ChatSession + history drivers + truncation) for AgentForge
|
|
5
5
|
Project-URL: Homepage, https://github.com/Scaffoldic/agentforge-py
|
|
6
6
|
Project-URL: Repository, https://github.com/Scaffoldic/agentforge-py
|
|
@@ -19,10 +19,9 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
21
|
Requires-Python: >=3.13
|
|
22
|
-
Requires-Dist: agentforge-core~=0.2.
|
|
23
|
-
Requires-Dist: agentforge-py~=0.2.
|
|
24
|
-
|
|
25
|
-
Requires-Dist: aiosqlite>=0.20; extra == 'sqlite'
|
|
22
|
+
Requires-Dist: agentforge-core~=0.2.4
|
|
23
|
+
Requires-Dist: agentforge-py~=0.2.4
|
|
24
|
+
Requires-Dist: aiosqlite>=0.20
|
|
26
25
|
Description-Content-Type: text/markdown
|
|
27
26
|
|
|
28
27
|
# agentforge-chat
|
|
@@ -39,10 +38,11 @@ for the design and runbook.
|
|
|
39
38
|
|
|
40
39
|
```bash
|
|
41
40
|
pip install agentforge-chat
|
|
42
|
-
# or, with the SQLite driver pre-pulled:
|
|
43
|
-
pip install "agentforge-chat[sqlite]"
|
|
44
41
|
```
|
|
45
42
|
|
|
43
|
+
The SQLite history driver (`SqliteChatHistory`) works out of the box —
|
|
44
|
+
`aiosqlite` ships as a hard dependency.
|
|
45
|
+
|
|
46
46
|
## Three-line chat from a one-shot agent
|
|
47
47
|
|
|
48
48
|
```python
|
|
@@ -12,10 +12,11 @@ for the design and runbook.
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
pip install agentforge-chat
|
|
15
|
-
# or, with the SQLite driver pre-pulled:
|
|
16
|
-
pip install "agentforge-chat[sqlite]"
|
|
17
15
|
```
|
|
18
16
|
|
|
17
|
+
The SQLite history driver (`SqliteChatHistory`) works out of the box —
|
|
18
|
+
`aiosqlite` ships as a hard dependency.
|
|
19
|
+
|
|
19
20
|
## Three-line chat from a one-shot agent
|
|
20
21
|
|
|
21
22
|
```python
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
[project]
|
|
11
11
|
name = "agentforge-chat"
|
|
12
|
-
version = "0.2.
|
|
12
|
+
version = "0.2.4"
|
|
13
13
|
description = "Chat-agent runtime (ChatSession + history drivers + truncation) for AgentForge"
|
|
14
14
|
readme = "README.md"
|
|
15
15
|
requires-python = ">=3.13"
|
|
@@ -30,13 +30,16 @@ classifiers = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
dependencies = [
|
|
33
|
-
"agentforge-core ~= 0.2.
|
|
34
|
-
"agentforge-py ~= 0.2.
|
|
33
|
+
"agentforge-core ~= 0.2.4",
|
|
34
|
+
"agentforge-py ~= 0.2.4",
|
|
35
|
+
# aiosqlite is a hard dependency, not an optional extra: the package
|
|
36
|
+
# eagerly imports SqliteChatHistory (agentforge_chat/__init__.py ->
|
|
37
|
+
# sqlite.py -> `import aiosqlite`), so `import agentforge_chat` fails
|
|
38
|
+
# without it. Mirrors the sibling agentforge-memory-sqlite, which also
|
|
39
|
+
# hard-deps aiosqlite. (bug-015 audit.)
|
|
40
|
+
"aiosqlite>=0.20",
|
|
35
41
|
]
|
|
36
42
|
|
|
37
|
-
[project.optional-dependencies]
|
|
38
|
-
sqlite = ["aiosqlite>=0.20"]
|
|
39
|
-
|
|
40
43
|
[project.urls]
|
|
41
44
|
Homepage = "https://github.com/Scaffoldic/agentforge-py"
|
|
42
45
|
Repository = "https://github.com/Scaffoldic/agentforge-py"
|
|
@@ -48,6 +48,7 @@ async def build_chat_session_from_config(
|
|
|
48
48
|
per_session = None
|
|
49
49
|
idem_window = 60.0
|
|
50
50
|
safety_mode: SafetyMode = "buffer-then-stream"
|
|
51
|
+
persist_steps = True
|
|
51
52
|
if chat_cfg is not None:
|
|
52
53
|
if chat_cfg.history is not None:
|
|
53
54
|
history = await _build_history(chat_cfg.history.driver, chat_cfg.history.config)
|
|
@@ -57,6 +58,7 @@ async def build_chat_session_from_config(
|
|
|
57
58
|
per_session = chat_cfg.session.per_session_budget_usd
|
|
58
59
|
idem_window = chat_cfg.session.idempotency_window_s
|
|
59
60
|
safety_mode = chat_cfg.session.safety_mode
|
|
61
|
+
persist_steps = chat_cfg.session.persist_steps
|
|
60
62
|
return ChatSession(
|
|
61
63
|
agent=agent,
|
|
62
64
|
session_id=session_id,
|
|
@@ -68,6 +70,7 @@ async def build_chat_session_from_config(
|
|
|
68
70
|
per_session_budget_usd=per_session,
|
|
69
71
|
idempotency_window_s=idem_window,
|
|
70
72
|
safety_mode=safety_mode,
|
|
73
|
+
persist_steps=persist_steps,
|
|
71
74
|
)
|
|
72
75
|
|
|
73
76
|
|
|
@@ -77,7 +77,10 @@ class InMemoryChatHistory(ChatHistoryStore):
|
|
|
77
77
|
before: datetime | None = None,
|
|
78
78
|
) -> list[SessionInfo]:
|
|
79
79
|
async with self._lock:
|
|
80
|
-
|
|
80
|
+
# Include sessions that exist only via metadata (created before
|
|
81
|
+
# their first turn — bug-018), not just sessions with turns.
|
|
82
|
+
sids = set(self._turns) | set(self._meta)
|
|
83
|
+
out = [self._build_info(sid) for sid in sids]
|
|
81
84
|
if owner is not None:
|
|
82
85
|
out = [s for s in out if s.owner == owner]
|
|
83
86
|
if before is not None:
|
|
@@ -92,6 +95,11 @@ class InMemoryChatHistory(ChatHistoryStore):
|
|
|
92
95
|
bag[k] = v
|
|
93
96
|
if "owner" in metadata:
|
|
94
97
|
self._owners[session_id] = metadata["owner"]
|
|
98
|
+
# Register timestamps so a metadata-only session (created before
|
|
99
|
+
# its first turn — bug-018) carries sane created/last_active.
|
|
100
|
+
now = datetime.now(UTC)
|
|
101
|
+
self._created_at.setdefault(session_id, now)
|
|
102
|
+
self._last_active.setdefault(session_id, now)
|
|
95
103
|
|
|
96
104
|
async def expire_before(self, cutoff: datetime) -> int:
|
|
97
105
|
async with self._lock:
|
|
@@ -11,7 +11,7 @@ distribution:
|
|
|
11
11
|
config_block:
|
|
12
12
|
modules.chat:
|
|
13
13
|
history:
|
|
14
|
-
driver: memory # memory | sqlite (
|
|
14
|
+
driver: memory # memory | sqlite (both ship with agentforge-chat)
|
|
15
15
|
config: {}
|
|
16
16
|
truncation:
|
|
17
17
|
strategy: sliding_window
|
|
@@ -23,6 +23,7 @@ streaming work documented in feat-020 §10 deferrals.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import asyncio
|
|
26
|
+
import json
|
|
26
27
|
import time
|
|
27
28
|
from collections.abc import AsyncIterator, Callable
|
|
28
29
|
from typing import Any, Literal
|
|
@@ -36,6 +37,7 @@ from agentforge_core.production.exceptions import (
|
|
|
36
37
|
GuardrailViolation,
|
|
37
38
|
)
|
|
38
39
|
from agentforge_core.values.chat import ChatChunk, ChatResponse, ChatTurn, StreamingEvent
|
|
40
|
+
from agentforge_core.values.messages import ToolCall
|
|
39
41
|
|
|
40
42
|
from agentforge_chat._idempotency import IdempotencyCache
|
|
41
43
|
from agentforge_chat._locks import (
|
|
@@ -74,6 +76,7 @@ class ChatSession:
|
|
|
74
76
|
on_turn: OnTurnHook | None = None,
|
|
75
77
|
session_lock_factory: SessionLockFactory | None = None,
|
|
76
78
|
safety_mode: SafetyMode = "buffer-then-stream",
|
|
79
|
+
persist_steps: bool = True,
|
|
77
80
|
) -> None:
|
|
78
81
|
self._agent = agent
|
|
79
82
|
self._session_id = session_id if session_id is not None else uuid4().hex
|
|
@@ -97,6 +100,7 @@ class ChatSession:
|
|
|
97
100
|
self._turn_count = 0
|
|
98
101
|
self._closed = False
|
|
99
102
|
self._safety_mode: SafetyMode = safety_mode
|
|
103
|
+
self._persist_steps = persist_steps
|
|
100
104
|
|
|
101
105
|
# ------------------------------------------------------------------
|
|
102
106
|
# Public properties
|
|
@@ -200,13 +204,17 @@ class ChatSession:
|
|
|
200
204
|
result = await self._agent.run(task)
|
|
201
205
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
202
206
|
validated_out = await self._agent._guardrails.check_output(self._extract_text(result), ctx)
|
|
207
|
+
# Persist intermediate tool steps BEFORE the final assistant turn so
|
|
208
|
+
# the chat history reflects causal ordering (user → tool calls →
|
|
209
|
+
# tool results → final answer). bug-010.
|
|
210
|
+
tool_calls = await self._persist_steps_from_result(result)
|
|
203
211
|
assistant_turn = await self._persist_assistant(validated_out, result, duration_ms)
|
|
204
212
|
self._enforce_budgets(result.cost_usd)
|
|
205
213
|
response = ChatResponse(
|
|
206
214
|
content=validated_out,
|
|
207
215
|
turn_id=assistant_turn.id,
|
|
208
216
|
run_id=result.run_id,
|
|
209
|
-
tool_calls=
|
|
217
|
+
tool_calls=tool_calls,
|
|
210
218
|
tokens_in=result.tokens_in,
|
|
211
219
|
tokens_out=result.tokens_out,
|
|
212
220
|
cost_usd=result.cost_usd,
|
|
@@ -254,6 +262,103 @@ class ChatSession:
|
|
|
254
262
|
return output
|
|
255
263
|
return str(output)
|
|
256
264
|
|
|
265
|
+
async def _persist_steps_from_result(self, result: Any) -> tuple[ToolCall, ...]:
|
|
266
|
+
"""Persist `act` and `observe` steps from `result.steps` as
|
|
267
|
+
`ChatTurn`s ahead of the final assistant turn (bug-010).
|
|
268
|
+
|
|
269
|
+
Returns the aggregated tool_calls so the caller can attach them
|
|
270
|
+
to both the final assistant `ChatTurn.tool_calls` and the
|
|
271
|
+
synchronous `ChatResponse.tool_calls`. No-op when
|
|
272
|
+
`self._persist_steps` is False.
|
|
273
|
+
"""
|
|
274
|
+
if not self._persist_steps:
|
|
275
|
+
return ()
|
|
276
|
+
tool_calls: list[ToolCall] = []
|
|
277
|
+
for step in result.steps:
|
|
278
|
+
if step.tool_call is None:
|
|
279
|
+
continue
|
|
280
|
+
if step.kind == "act":
|
|
281
|
+
tool_calls.append(step.tool_call)
|
|
282
|
+
content = (
|
|
283
|
+
step.content if isinstance(step.content, str) else json.dumps(step.content)
|
|
284
|
+
)
|
|
285
|
+
turn = ChatTurn(
|
|
286
|
+
id=uuid4().hex,
|
|
287
|
+
session_id=self._session_id,
|
|
288
|
+
role="assistant",
|
|
289
|
+
content=content,
|
|
290
|
+
run_id=result.run_id,
|
|
291
|
+
tool_calls=(step.tool_call,),
|
|
292
|
+
tokens_in=step.tokens_in,
|
|
293
|
+
tokens_out=step.tokens_out,
|
|
294
|
+
cost_usd=step.cost_usd,
|
|
295
|
+
)
|
|
296
|
+
await self._history.append(turn)
|
|
297
|
+
if self._on_turn is not None:
|
|
298
|
+
self._on_turn(turn)
|
|
299
|
+
elif step.kind == "observe":
|
|
300
|
+
content = (
|
|
301
|
+
step.content if isinstance(step.content, str) else json.dumps(step.content)
|
|
302
|
+
)
|
|
303
|
+
turn = ChatTurn(
|
|
304
|
+
id=uuid4().hex,
|
|
305
|
+
session_id=self._session_id,
|
|
306
|
+
role="tool",
|
|
307
|
+
content=content,
|
|
308
|
+
tool_call_id=step.tool_call.id,
|
|
309
|
+
run_id=result.run_id,
|
|
310
|
+
)
|
|
311
|
+
await self._history.append(turn)
|
|
312
|
+
if self._on_turn is not None:
|
|
313
|
+
self._on_turn(turn)
|
|
314
|
+
return tuple(tool_calls)
|
|
315
|
+
|
|
316
|
+
async def _persist_steps_from_events(
|
|
317
|
+
self,
|
|
318
|
+
events: list[StreamingEvent],
|
|
319
|
+
*,
|
|
320
|
+
run_id: str,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Stream-path mirror of `_persist_steps_from_result` (bug-010).
|
|
323
|
+
|
|
324
|
+
Walks the buffered `kind="step"` events captured during
|
|
325
|
+
`agent.stream(...)`. `tool_call` rides on `event.metadata` as a
|
|
326
|
+
serialised dict (see `strategies._base._events_for_new_steps`)
|
|
327
|
+
so the session can persist tool turns without reaching into
|
|
328
|
+
`AgentState`.
|
|
329
|
+
"""
|
|
330
|
+
if not self._persist_steps:
|
|
331
|
+
return
|
|
332
|
+
for event in events:
|
|
333
|
+
meta = event.metadata
|
|
334
|
+
kind = meta.get("kind")
|
|
335
|
+
tool_call_dict = meta.get("tool_call")
|
|
336
|
+
if kind not in ("act", "observe") or not isinstance(tool_call_dict, dict):
|
|
337
|
+
continue
|
|
338
|
+
tool_call = ToolCall.model_validate(tool_call_dict)
|
|
339
|
+
content = event.content if isinstance(event.content, str) else json.dumps(event.content)
|
|
340
|
+
if kind == "act":
|
|
341
|
+
turn = ChatTurn(
|
|
342
|
+
id=uuid4().hex,
|
|
343
|
+
session_id=self._session_id,
|
|
344
|
+
role="assistant",
|
|
345
|
+
content=content,
|
|
346
|
+
run_id=run_id,
|
|
347
|
+
tool_calls=(tool_call,),
|
|
348
|
+
)
|
|
349
|
+
else: # observe
|
|
350
|
+
turn = ChatTurn(
|
|
351
|
+
id=uuid4().hex,
|
|
352
|
+
session_id=self._session_id,
|
|
353
|
+
role="tool",
|
|
354
|
+
content=content,
|
|
355
|
+
tool_call_id=tool_call.id,
|
|
356
|
+
run_id=run_id,
|
|
357
|
+
)
|
|
358
|
+
await self._history.append(turn)
|
|
359
|
+
if self._on_turn is not None:
|
|
360
|
+
self._on_turn(turn)
|
|
361
|
+
|
|
257
362
|
async def _persist_assistant(
|
|
258
363
|
self,
|
|
259
364
|
text: str,
|
|
@@ -343,7 +448,7 @@ class ChatSession:
|
|
|
343
448
|
async for chunk in self._chunks_for(response):
|
|
344
449
|
yield chunk
|
|
345
450
|
|
|
346
|
-
async def _stream_per_token( # noqa: PLR0912
|
|
451
|
+
async def _stream_per_token( # noqa: PLR0912, PLR0915
|
|
347
452
|
self,
|
|
348
453
|
message: str,
|
|
349
454
|
*,
|
|
@@ -370,6 +475,10 @@ class ChatSession:
|
|
|
370
475
|
assistant_turn_id = uuid4().hex
|
|
371
476
|
cumulative = ""
|
|
372
477
|
run_summary: dict[str, Any] | None = None
|
|
478
|
+
# Collected step events (bug-010): persisted as ChatTurns after
|
|
479
|
+
# the stream finishes but before the final assistant turn, so
|
|
480
|
+
# tool calls + results appear in causal order on disk.
|
|
481
|
+
step_events: list[StreamingEvent] = []
|
|
373
482
|
start = time.monotonic()
|
|
374
483
|
buffered = self._safety_mode in ("sentence-window", "stream-then-redact")
|
|
375
484
|
window = _SentenceWindowBuffer() if buffered else None
|
|
@@ -381,6 +490,8 @@ class ChatSession:
|
|
|
381
490
|
# event and complete naturally so `Agent.stream`'s
|
|
382
491
|
# `finally: reset_run(token)` fires deterministically.
|
|
383
492
|
continue
|
|
493
|
+
if event.kind == "step":
|
|
494
|
+
step_events.append(event)
|
|
384
495
|
if event.kind == "text" and isinstance(event.content, str):
|
|
385
496
|
if window is not None:
|
|
386
497
|
for sentence in window.push(event.content):
|
|
@@ -431,6 +542,11 @@ class ChatSession:
|
|
|
431
542
|
else cumulative
|
|
432
543
|
)
|
|
433
544
|
validated_out = await self._agent._guardrails.check_output(final_text, ctx)
|
|
545
|
+
# Persist intermediate tool steps before the final assistant
|
|
546
|
+
# turn (bug-010, stream path mirror of `_persist_steps_from_result`).
|
|
547
|
+
await self._persist_steps_from_events(
|
|
548
|
+
step_events, run_id=str(run_summary.get("run_id", ""))
|
|
549
|
+
)
|
|
434
550
|
assistant_turn = ChatTurn(
|
|
435
551
|
id=assistant_turn_id,
|
|
436
552
|
session_id=self._session_id,
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
13
|
from collections.abc import Mapping
|
|
14
|
-
from datetime import datetime
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from types import TracebackType
|
|
17
17
|
from typing import Any
|
|
@@ -200,12 +200,24 @@ class SqliteChatHistory(ChatHistoryStore):
|
|
|
200
200
|
return [await self._row_to_info(row) for row in rows]
|
|
201
201
|
|
|
202
202
|
async def update_session_metadata(self, session_id: str, metadata: Mapping[str, Any]) -> None:
|
|
203
|
+
# Create the session row if it doesn't exist yet (bug-018):
|
|
204
|
+
# ChatServer records owner/metadata before the first turn is
|
|
205
|
+
# appended. DO NOTHING leaves an existing row (and its
|
|
206
|
+
# last_active_at) untouched.
|
|
207
|
+
now_iso = datetime.now(UTC).isoformat()
|
|
208
|
+
await self._db.execute(
|
|
209
|
+
"""INSERT INTO chat_sessions
|
|
210
|
+
(id, owner, created_at, last_active_at, metadata)
|
|
211
|
+
VALUES (?, NULL, ?, ?, ?)
|
|
212
|
+
ON CONFLICT(id) DO NOTHING""",
|
|
213
|
+
(session_id, now_iso, now_iso, "{}"),
|
|
214
|
+
)
|
|
203
215
|
async with self._db.execute(
|
|
204
216
|
"SELECT metadata, owner FROM chat_sessions WHERE id = ?",
|
|
205
217
|
(session_id,),
|
|
206
218
|
) as cur:
|
|
207
219
|
row = await cur.fetchone()
|
|
208
|
-
if row is None:
|
|
220
|
+
if row is None: # pragma: no cover — the INSERT above guarantees a row
|
|
209
221
|
raise ModuleError(f"Cannot update metadata for unknown session {session_id!r}")
|
|
210
222
|
existing = json.loads(row["metadata"])
|
|
211
223
|
existing.update(dict(metadata))
|
|
@@ -19,6 +19,7 @@ from agentforge_core.values.messages import (
|
|
|
19
19
|
LLMResponse,
|
|
20
20
|
Message,
|
|
21
21
|
TokenUsage,
|
|
22
|
+
ToolCall,
|
|
22
23
|
ToolSpec,
|
|
23
24
|
)
|
|
24
25
|
from agentforge_core.values.state import AgentState, Step
|
|
@@ -241,3 +242,110 @@ async def test_close_is_idempotent() -> None:
|
|
|
241
242
|
session = _session(session_id="t14")
|
|
242
243
|
await session.close()
|
|
243
244
|
await session.close()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ----------------------------------------------------------------------
|
|
248
|
+
# bug-010 — persist intermediate tool steps to history
|
|
249
|
+
# ----------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _ToolUseStrategy(ReasoningStrategy):
|
|
253
|
+
"""Strategy that simulates one think/act/observe iteration with a
|
|
254
|
+
tool call, then a final think with the answer. Used to exercise
|
|
255
|
+
bug-010 persistence."""
|
|
256
|
+
|
|
257
|
+
def __init__(self, *, tool_id: str = "tc-1", tool_name: str = "ping") -> None:
|
|
258
|
+
self._tool_id = tool_id
|
|
259
|
+
self._tool_name = tool_name
|
|
260
|
+
|
|
261
|
+
async def run(self, state: AgentState) -> AgentState:
|
|
262
|
+
runtime = state.metadata.get(RUNTIME_KEY)
|
|
263
|
+
if runtime is not None:
|
|
264
|
+
runtime.budget.commit(0.01)
|
|
265
|
+
tc = ToolCall(id=self._tool_id, name=self._tool_name, arguments={"target": "x"})
|
|
266
|
+
state.steps.append(Step(iteration=0, kind="think", content="planning"))
|
|
267
|
+
state.steps.append(
|
|
268
|
+
Step(
|
|
269
|
+
iteration=0,
|
|
270
|
+
kind="act",
|
|
271
|
+
content={"tool": tc.name, "arguments": dict(tc.arguments)},
|
|
272
|
+
tool_call=tc,
|
|
273
|
+
cost_usd=0.0,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
state.steps.append(
|
|
277
|
+
Step(
|
|
278
|
+
iteration=0,
|
|
279
|
+
kind="observe",
|
|
280
|
+
content='{"ok": true}',
|
|
281
|
+
tool_call=tc,
|
|
282
|
+
cost_usd=0.0,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
state.steps.append(
|
|
286
|
+
Step(iteration=1, kind="think", content="all good", cost_usd=0.01),
|
|
287
|
+
)
|
|
288
|
+
return state
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _tool_session(**kwargs: Any) -> ChatSession:
|
|
292
|
+
agent = Agent(model=_FakeLLM(), strategy=_ToolUseStrategy())
|
|
293
|
+
return ChatSession(agent, history_store=InMemoryChatHistory(), **kwargs)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.mark.asyncio
|
|
297
|
+
async def test_persist_steps_records_act_and_observe_turns() -> None:
|
|
298
|
+
"""bug-010: tool steps from result.steps must persist to chat history
|
|
299
|
+
so generative-UI clients can render them and the next turn's prompt
|
|
300
|
+
sees prior tool context."""
|
|
301
|
+
session = _tool_session(session_id="t-bug010-run")
|
|
302
|
+
await session.send("do the thing")
|
|
303
|
+
turns = await session.history()
|
|
304
|
+
roles_then_ids = [(t.role, t.tool_call_id, t.tool_calls) for t in turns]
|
|
305
|
+
# Expected shape: user, assistant(act with tool_calls), tool(observation),
|
|
306
|
+
# assistant(final answer).
|
|
307
|
+
assert [r for r, _, _ in roles_then_ids] == ["user", "assistant", "tool", "assistant"]
|
|
308
|
+
_, _, act_tcs = roles_then_ids[1]
|
|
309
|
+
assert len(act_tcs) == 1
|
|
310
|
+
assert act_tcs[0].id == "tc-1"
|
|
311
|
+
assert act_tcs[0].name == "ping"
|
|
312
|
+
_, observe_tcid, _ = roles_then_ids[2]
|
|
313
|
+
assert observe_tcid == "tc-1"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@pytest.mark.asyncio
|
|
317
|
+
async def test_response_tool_calls_populated_from_steps() -> None:
|
|
318
|
+
"""bug-010: ChatResponse.tool_calls aggregates the tool calls from
|
|
319
|
+
`result.steps` instead of being the previously-hardcoded empty tuple."""
|
|
320
|
+
session = _tool_session(session_id="t-bug010-resp")
|
|
321
|
+
response = await session.send("do the thing")
|
|
322
|
+
assert len(response.tool_calls) == 1
|
|
323
|
+
assert response.tool_calls[0].id == "tc-1"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@pytest.mark.asyncio
|
|
327
|
+
async def test_persist_steps_false_keeps_history_lean() -> None:
|
|
328
|
+
"""bug-010 opt-out: setting persist_steps=False reverts to the
|
|
329
|
+
pre-fix shape (user + final assistant only). Useful when an
|
|
330
|
+
external consumer reconstructs tool history from another source."""
|
|
331
|
+
session = _tool_session(session_id="t-bug010-off", persist_steps=False)
|
|
332
|
+
response = await session.send("do the thing")
|
|
333
|
+
turns = await session.history()
|
|
334
|
+
assert [t.role for t in turns] == ["user", "assistant"]
|
|
335
|
+
# And the response still surfaces an empty tool_calls (opt-out is
|
|
336
|
+
# consistent across persistence + response).
|
|
337
|
+
assert response.tool_calls == ()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@pytest.mark.asyncio
|
|
341
|
+
async def test_stream_persists_step_turns() -> None:
|
|
342
|
+
"""bug-010 stream path: `_stream_per_token` must persist tool turns
|
|
343
|
+
via `_persist_steps_from_events` so streaming and non-streaming
|
|
344
|
+
paths produce the same on-disk shape."""
|
|
345
|
+
session = _tool_session(session_id="t-bug010-stream")
|
|
346
|
+
async for _ in await session.stream("do the thing"):
|
|
347
|
+
pass
|
|
348
|
+
turns = await session.history()
|
|
349
|
+
assert [t.role for t in turns] == ["user", "assistant", "tool", "assistant"]
|
|
350
|
+
# tool turn pairs by id with the prior assistant turn's tool_calls
|
|
351
|
+
assert turns[1].tool_calls[0].id == turns[2].tool_call_id
|
|
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
|