Skip to content

Commit 53c2e82

Browse files
authored
Merge pull request #170 from PlateerLab/feat/task-memory
feat(memory): TASK 기억, 여러 단계·여러 턴 일을 하나의 기억 단위로 (4.98.0)
2 parents d2e5b96 + b27101e commit 53c2e82

19 files changed

Lines changed: 2101 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,38 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format
44
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
55
this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [4.98.0] - 2026-10-23
8+
9+
### Added: TASK 기억, 여러 단계·여러 턴 일을 하나의 기억 단위로 (`memory.tasks`)
10+
11+
사실은 사용자가 누구인지, 원문은 무슨 말이 오갔는지를 담는다. 지금 무슨 일을 하던 중인지는 어디에도 없었다. 여러 턴짜리
12+
요청은 원문에만 있었고, 창이 그 턴을 지나거나 압축이 요약으로 접으면 목표·계획·결정·파일이 함께 사라졌다. "계속" 은 이어 갈
13+
것이 없었다.
14+
15+
- **기억 층**: `Task`(제목, 목표, 완료 조건, 상태, 계획, 결정, 파일, 다음 할 일, 프로젝트)와 `TaskEvent`(열기·바꾸기·턴·
16+
계획·체크포인트·메모·닫기). 저장 계약 `TaskStore`(호스트 구현, 사람 단위 범위), 참조 구현 `InMemoryTaskStore`.
17+
provider 의 선택 층 `tasks()`(`provider_tasks()` 로 읽는다). 대화를 넘어 이어진다.
18+
- **판단(모델 선언 + 호스트 보완)**: 새 도구 `Task(action=open|update|switch|close|list)`. 호스트는 턴이 끝나면 LLM 없이
19+
기록한다. 현재 TASK 에 그 턴(요청·답·도구 수·파일·결과)을 덧붙이고, TodoWrite 목록을 계획으로 옮기며, 압축 요약을
20+
체크포인트로 남긴다. 모델이 열지 않았어도 여러 단계 일이 분명하면 요청으로 TASK 를 연다(호스트가 열었다고 적는다).
21+
기준은 끝나지 않은 계획 2단계 이상, 또는 도구를 쓰다 끝나지 못한 턴이다. 도구도 Task 도 쓰지 않은 짧은 질문은 현재
22+
TASK 의 기록에 넣지 않는다.
23+
- **회상**: 매 턴 TASK 판(`TaskBoardBlock`, volatile)이 실린다. 이 대화의 현재 TASK 는 전부, 다른 열린 TASK 는 한 줄씩이다.
24+
검색을 거치지 않으므로 "계속" 에도 일의 상태가 온다. 판의 몫은 창의 5%(최소 3,000자)이고, 넘치면 오래된 항목부터 빼고
25+
뺀 수와 `TaskRecall` 을 적는다. 기억 검색 질의에 현재 TASK 의 제목·목표를 더한다. 새 도구
26+
`TaskRecall(view=card|history|turn)` 는 TASK 의 전체 기록을 읽는다. 그 턴의 원문도 읽는데, 이 대화는 ConversationHistory,
27+
다른 대화는 provider 의 `session_stm(session_id)` 로 읽는다. TASK 마다 `tasks/task-<id>.md` 노트를 만들어 기억 검색에도 오른다.
28+
- TASK 노트는 기록에서 다시 쓰는 화면용이라고 첫 줄에 적는다(노트를 고쳐도 TASK 는 바뀌지 않는다). 이 대화의 현재 TASK
29+
노트는 판이 이미 실으므로 기억 검색에서 뺀다.
30+
- 판의 사용 안내(Task 를 부르라는 말)는 그 호출에 Task 도구가 있을 때만 붙는다. 메모리 도구가 없는 백엔드에는 "호스트가
31+
기록한다" 만 적는다. Stage 3 은 블록을 그리기 전에 이번 호출에 나가는 도구 이름을 알린다(`_offered_tools`).
32+
- TASK 는 사람의 것이다. 노트가 에이전트 공용인 provider 는 `task_notes = False` 로 TASK 노트를 받지 않는다(다른 사람이
33+
기억 검색으로 남의 TASK 를 보지 않게). 그런 provider 에서는 판과 `Task(action="list", query=...)`(열린·닫힌 TASK 를 낱말로
34+
찾는다)로 찾는다.
35+
- 실행 기록(Executions)과 잇는다: 실행 카드에 그 턴이 기록된 TASK(`> **Task:** [id] 제목`, frontmatter `task_id`)를,
36+
날짜 일지 줄에 `task <id>` 를 적는다.
37+
- 이벤트 `context.task_board`(카탈로그 v19).
38+
739
## [4.97.0] - 2026-10-23
840

941
### Fixed: 모델과 사용자가 받는 것이 실제와 같다 (정직성 감사)

‎docs/events.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<!-- Regenerate: python scripts/gen_event_docs.py -->
55

66
> Generated from `xgen_agent_runtime.events.catalog` on 2026-10-23.
7-
> Catalogue version: **18** · events: **145**
7+
> Catalogue version: **19** · events: **146**
88
99
Every event name the engine emits, value == wire string. The enum
1010
is a *names registry*, not a rename — consumers matching raw strings
@@ -445,6 +445,15 @@ Enum member: `EventTypes.CONTEXT_SHORT_TERM_WINDOW`
445445
| `chars` | int — 창의 직렬화 길이 |
446446
| `degraded` | list[str] — 예산 강등 단계 |
447447

448+
### `context.task_board`
449+
450+
Enum member: `EventTypes.CONTEXT_TASK_BOARD`
451+
452+
| Field | Description |
453+
|---|---|
454+
| `current` | str — 이 대화의 현재 TASK id (없으면 빈 문자열) |
455+
| `chars` | int — 판의 길이 |
456+
448457
## Stage 18 — Memory (+ Stage 2 compaction)
449458

450459
### `memory.compaction.summarized`

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "xgen-agent-runtime"
7-
version = "4.97.0"
7+
version = "4.98.0"
88
description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API"
99
readme = "README.md"
1010
license = "Apache-2.0"

‎src/xgen_agent_runtime/core/compaction.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,44 @@ def _summary_text(state: PipelineState) -> str:
315315
return ""
316316

317317

318+
async def _keep_checkpoint_on_task(
319+
state: Any, provider: Any, *, trigger: str, replaced: int
320+
) -> None:
321+
"""A compaction's summary goes onto this conversation's current task (memory.tasks).
322+
323+
The checkpoint's sections (requests, work done, decisions, problems, open items, current state)
324+
are the task's own state. Kept on the task, a compaction tidies the task's record instead of
325+
being the moment the work's details were lost. Best-effort: the checkpoint itself is in context.
326+
"""
327+
try:
328+
from xgen_agent_runtime.memory.tasks import CURRENT_KEY, TaskEvent, provider_tasks
329+
330+
store = provider_tasks(provider)
331+
if store is None:
332+
return
333+
session_id = str(getattr(state, "session_id", "") or "")
334+
task = await store.current(session_id)
335+
if task is None:
336+
task_id = str((getattr(state, "shared", None) or {}).get(CURRENT_KEY) or "")
337+
task = await store.get(task_id) if task_id else None
338+
if task is None:
339+
return
340+
summary = _summary_text(state)
341+
if not summary.strip():
342+
return
343+
await store.append_event(
344+
TaskEvent(
345+
task_id=task.id,
346+
kind="checkpoint",
347+
session_id=session_id,
348+
summary=f"context compacted ({trigger}, {replaced} messages summarized)",
349+
data={"summary": summary},
350+
)
351+
)
352+
except Exception: # noqa: BLE001
353+
logger.debug("could not keep the checkpoint on the task", exc_info=True)
354+
355+
318356
def _prune(state: PipelineState, *, aggressive: bool, trigger: str) -> Dict[str, int]:
319357
"""Deterministic pre-pass. In-place, count/order-preserving, pair-safe.
320358
@@ -625,6 +663,9 @@ async def run_compaction(
625663
)
626664
logger.debug("record_compaction failed: %s", exc)
627665

666+
if replaced > 0 and provider is not None:
667+
await _keep_checkpoint_on_task(state, provider, trigger=trigger, replaced=replaced)
668+
628669
return {
629670
"ok": True,
630671
"before_messages": before_msgs,

‎src/xgen_agent_runtime/events/catalog.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@
6767
#: v17: ``tool.call_start`` / ``tool.call_complete`` gain ``capabilities`` / ``origin``.
6868
#: v18: + ``loop.review_start`` / ``loop.review_end`` (4.95.0 the completion review's span, so a host can
6969
#: say "checking the files in the answer" while it reads them).
70-
EVENT_CATALOG_VERSION = 18
70+
#: v19: + ``context.task_board`` (4.98.0 TASK memory: the board loaded at the start of each turn).
71+
EVENT_CATALOG_VERSION = 19
7172

7273

7374
class EventTypes(str, Enum):
@@ -142,6 +143,8 @@ class EventTypes(str, Enum):
142143
#: 단기 기억 창(4.36.0) — 호스트가 이력을 preload 하지 않은 턴에서 STM 의 최근 논리 턴을
143144
#: messages 앞에 되살렸다(가까운 턴은 도구까지, 먼 턴은 대화만).
144145
CONTEXT_SHORT_TERM_WINDOW = "context.short_term_window"
146+
#: TASK 판(4.98.0) — 이 대화의 현재 TASK 와 열린 TASK 를 이번 턴 프롬프트에 실었다.
147+
CONTEXT_TASK_BOARD = "context.task_board"
145148
MEMORY_COMPACTION_SUMMARIZED = "memory.compaction.summarized"
146149
MEMORY_COMPACTION_LLM_FAILED = "memory.compaction.llm_failed"
147150

@@ -464,6 +467,10 @@ class EventTypes(str, Enum):
464467
"chars": "int — 창의 직렬화 길이",
465468
"degraded": "list[str] — 예산 강등 단계",
466469
},
470+
EventTypes.CONTEXT_TASK_BOARD: {
471+
"current": "str — 이 대화의 현재 TASK id (없으면 빈 문자열)",
472+
"chars": "int — 판의 길이",
473+
},
467474
EventTypes.CONTEXT_COMPACTED: {
468475
"strategy": "str — compactor name/class, or 'prune' when the deterministic pass alone met the target",
469476
"trigger": "str? — proactive | requested | manual | guard | overflow | internal_loop | background",

‎src/xgen_agent_runtime/host/execution_record.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ def _build_card_body(
112112
tool_calls: int = 0,
113113
tool_failures: int = 0,
114114
blocked: int = 0,
115+
task_id: str = "",
116+
task_title: str = "",
115117
) -> str:
116118
"""Geny ``_build_execution_entry`` 형식 미러 (구조 동일, 필드 간소)."""
117119
outcome = outcome or classify_outcome(
@@ -124,6 +126,9 @@ def _build_card_body(
124126
f"> **Task:** {_clip(input_text, _TASK_CLIP)}",
125127
f"> **Duration:** {secs:.1f}s · **Session:** {session_id or '-'}",
126128
]
129+
if task_id:
130+
# 이 턴이 기록된 TASK(memory.tasks) — 실행 기록과 일을 잇는다.
131+
lines.append(f"> **Task:** [{task_id}] {_clip(task_title, 80)}")
127132
if tool_calls:
128133
tools_line = f"> **Tools:** {int(tool_calls)} calls · {int(tool_failures)} failed"
129134
if blocked:
@@ -166,6 +171,8 @@ async def record_turn_execution(
166171
tool_calls: int = 0,
167172
tool_failures: int = 0,
168173
blocked: int = 0,
174+
task_id: str = "",
175+
task_title: str = "",
169176
) -> None:
170177
"""실행 1회를 daily 카드 + executions 저널에 기록 (best-effort).
171178
@@ -219,6 +226,8 @@ async def record_turn_execution(
219226
tool_calls=tool_calls,
220227
tool_failures=tool_failures,
221228
blocked=blocked,
229+
task_id=task_id,
230+
task_title=task_title,
222231
)
223232
try:
224233
await notes.write(
@@ -237,6 +246,7 @@ async def record_turn_execution(
237246
"tool_calls": int(tool_calls),
238247
"tool_failures": int(tool_failures),
239248
"duration_ms": int(duration_ms),
249+
"task_id": task_id or None,
240250
},
241251
)
242252
)
@@ -249,6 +259,7 @@ async def record_turn_execution(
249259
line = (
250260
f"- {now.strftime('%H:%M')} {mark} #{number} "
251261
f"{_clip(input_text, 60)} ({duration_ms / 1000.0:.1f}s)"
262+
+ (f" · task {task_id}" if task_id else "")
252263
)
253264
try:
254265
existing = await notes.read(journal_filename)

‎src/xgen_agent_runtime/host/memory_tools.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@ def _schema(props: Dict[str, Any], required: Optional[List[str]] = None) -> Dict
429429
capabilities=read_caps,
430430
),
431431
]
432+
# TASK 층(memory.tasks)이 있는 provider 면 Task·TaskRecall 도 준다.
433+
from xgen_agent_runtime.host.task_tools import build_task_tools
434+
435+
tools.extend(build_task_tools(provider))
432436
# 종류 표지 — 호스트의 결과 필터가 이름이 아니라 종류로 판정한다(tools.base.tool_origin).
433437
return [with_origin(t, "memory") for t in tools]
434438

‎src/xgen_agent_runtime/host/runner.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ def build_pipeline(
778778
DateTimeBlock,
779779
PinnedFactsBlock,
780780
RetrievedMemoryBlock,
781+
TaskBoardBlock,
781782
TurnNotesBlock,
782783
)
783784

@@ -807,6 +808,7 @@ def build_pipeline(
807808
PinnedFactsBlock(),
808809
DateTimeBlock(),
809810
RetrievedMemoryBlock(),
811+
TaskBoardBlock(),
810812
TurnNotesBlock(),
811813
]
812814
),
@@ -1277,6 +1279,19 @@ def _record_execution(
12771279
return
12781280
spec = getattr(pipeline, "_memory_distill_spec", None)
12791281
calls, failures, blocked = _tool_stats_from_events(state)
1282+
# TASK 먼저: 실행 카드가 이 턴이 기록된 TASK 를 적는다(Executions ↔ TASK 연결).
1283+
task = _record_task_turn(
1284+
provider,
1285+
loop,
1286+
state=state,
1287+
input_text=input_text,
1288+
output_text=output_text,
1289+
success=success,
1290+
cancelled=cancelled,
1291+
calls=calls,
1292+
failures=failures,
1293+
blocked=blocked,
1294+
)
12801295
try:
12811296
from xgen_agent_runtime.host.execution_record import record_turn_execution
12821297

@@ -1297,6 +1312,8 @@ def _record_execution(
12971312
tool_calls=calls,
12981313
tool_failures=failures,
12991314
blocked=blocked,
1315+
task_id=getattr(task, "id", "") or "",
1316+
task_title=getattr(task, "title", "") or "",
13001317
),
13011318
timeout=10.0,
13021319
)
@@ -1305,6 +1322,84 @@ def _record_execution(
13051322
logger.debug("geny_bridge: execution record failed (turn unaffected)", exc_info=True)
13061323

13071324

1325+
def _record_task_turn(
1326+
provider: Any,
1327+
loop: asyncio.AbstractEventLoop,
1328+
*,
1329+
state: PipelineState,
1330+
input_text: Any,
1331+
output_text: str,
1332+
success: bool,
1333+
cancelled: bool,
1334+
calls: int,
1335+
failures: int,
1336+
blocked: int,
1337+
) -> Any:
1338+
"""이 턴이 한 일을 이 대화의 현재 TASK 에 남긴다(memory.tasks, LLM 없음). 기록한 TASK 를 돌려준다.
1339+
1340+
모델이 TASK 를 열지 않았어도 여러 단계 일이 분명하면(끝나지 않은 계획 2단계 이상, 도구를 쓰다
1341+
끝나지 못한 턴) 요청으로 TASK 를 연다(호스트가 열었다고 적는다). 실패는 로그만.
1342+
"""
1343+
from xgen_agent_runtime.memory.tasks import (
1344+
CURRENT_KEY,
1345+
provider_tasks,
1346+
record_turn,
1347+
tool_calls_in,
1348+
turn_messages,
1349+
)
1350+
1351+
store = provider_tasks(provider)
1352+
if store is None:
1353+
return None
1354+
try:
1355+
from xgen_agent_runtime.host.execution_record import _as_text, classify_outcome
1356+
from xgen_agent_runtime.host.task_tools import sync_task_note
1357+
from xgen_agent_runtime.stages.s16_loop.completion_review import written_paths
1358+
1359+
msgs = turn_messages(list(state.messages or []))
1360+
n_calls = max(int(calls or 0), tool_calls_in(msgs))
1361+
outcome = classify_outcome(
1362+
success,
1363+
tool_calls=n_calls,
1364+
tool_failures=failures,
1365+
blocked=blocked,
1366+
cancelled=cancelled,
1367+
)
1368+
# TodoWrite 를 이번 턴에 썼을 때만 그 목록이 이 TASK 의 계획이다(지난 턴의 목록을 다른 TASK 에
1369+
# 덮어쓰지 않는다).
1370+
used = {
1371+
str(b.get("name") or "")
1372+
for m in msgs
1373+
if isinstance(m, dict) and isinstance(m.get("content"), list)
1374+
for b in m["content"]
1375+
if isinstance(b, dict) and b.get("type") == "tool_use"
1376+
}
1377+
wrote_todos = "TodoWrite" in used
1378+
1379+
async def _go() -> Any:
1380+
task = await record_turn(
1381+
store,
1382+
session_id=str(getattr(state, "session_id", "") or ""),
1383+
request=_as_text(input_text),
1384+
answer=str(output_text or ""),
1385+
outcome=outcome,
1386+
tool_calls=n_calls,
1387+
tool_failures=int(failures or 0),
1388+
files=written_paths(msgs),
1389+
todos=state.shared.get("executor.todos") if wrote_todos else None,
1390+
current_id=str(state.shared.get(CURRENT_KEY) or ""),
1391+
task_tool_used="Task" in used,
1392+
)
1393+
if task is not None:
1394+
await sync_task_note(provider, store, task)
1395+
return task
1396+
1397+
return loop.run_until_complete(asyncio.wait_for(_go(), timeout=10.0))
1398+
except Exception: # noqa: BLE001 — 기록은 best-effort
1399+
logger.debug("geny_bridge: task record failed (turn unaffected)", exc_info=True)
1400+
return None
1401+
1402+
13081403
#: 끝나지 못한 턴을 기억에 남길 때 그 턴 끝에 붙이는 표식 — 다음 턴의 단기 기억 창이 이 턴을 "끝나지
13091404
#: 않은 턴" 으로 읽는다. 위의 도구 호출·결과는 그때까지 실제로 한 일이다.
13101405
UNFINISHED_TURN_NOTES = {

0 commit comments

Comments
 (0)