-
Notifications
You must be signed in to change notification settings - Fork 0
feat: added memory compaction to agent #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import json | ||
| from functools import cache | ||
|
|
||
| import tiktoken | ||
| from openai import AsyncOpenAI | ||
|
|
||
| from config import settings | ||
| from harness.system_prompt import SYSTEM_PROMPT | ||
|
|
||
| # Small on purpose so a short task triggers compaction. | ||
| MAX_CONTEXT_TOKENS = 500 | ||
| KEEP_CONTEXT_TOKENS = 200 | ||
|
|
||
| client = AsyncOpenAI(api_key=settings.openai_api_key) | ||
|
|
||
|
|
||
| @cache | ||
| def _encoder() -> tiktoken.Encoding: | ||
| # Built once, reused forever (lazy). | ||
| return tiktoken.get_encoding("o200k_base") | ||
|
|
||
|
|
||
| def _as_text(message: object) -> str: | ||
| # Messages are mixed dicts (role/content, function_call_output, ...) | ||
| return message if isinstance(message, str) else json.dumps(message) | ||
|
|
||
|
|
||
| # ── estimate: how many tokens a list of messages costs ────────────── | ||
| def estimate_tokens(messages: list) -> int: | ||
| text = "\n".join(_as_text(m) for m in messages) | ||
| # encode_ordinary never raises on text that looks like a special token | ||
| return len(_encoder().encode_ordinary(text)) | ||
|
|
||
|
|
||
| # ── CONTEXT: what the model actually sees THIS turn, assembled fresh ─ | ||
| def build_context(task: str, summary: str, turns: list) -> list: | ||
| context: list = [ | ||
| {"role": "system", "content": SYSTEM_PROMPT}, | ||
| {"role": "user", "content": task}, # the goal is pinned, never summarized away | ||
| ] | ||
| if summary: | ||
| context.append({"role": "system", "content": f"Summary of earlier work so far:\n{summary}"}) | ||
|
Comment on lines
+41
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | sed -n '1,200p'
echo "== memory.py summary context =="
fd -a 'memory.py' . | while read -r f; do
echo "--- $f"
wc -l "$f"
sed -n '1,180p' "$f" | cat -n
done
echo "== runtime.py agent instruction / summary search =="
fd -a 'runtime.py' . | while read -r f; do
echo "--- $f"
wc -l "$f"
rg -n "tool|instruction|summary|memory|turns|context|openai|anthropic|messages" "$f" || true
done
echo "== git diff stat/name =="
git diff --stat HEAD~1..HEAD 2>/dev/null || git diff --stat 2>/dev/null || true
git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only 2>/dev/null || trueRepository: inesaranab/agent-harness Length of output: 7198 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== runtime.py outline and relevant sections =="
wc -l harness/runtime.py
sed -n '1,220p' harness/runtime.py | cat -n
echo "== tools.py =="
wc -l harness/tools.py
sed -n '1,260p' harness/tools.py | cat -n
echo "== inspect prompt-injection model for generated summary/system authority =="
python3 - <<'PY'
from pathlib import Path
import ast, re
p=Path('harness/runtime.py')
src=p.read_text()
m=re.search(r'if estimate_tokens.*?summary\s*=\s*await\s*summarize_step\([^)]*\)(?P<body>.*?)(?=\n\n|\n # )', src, flags=re.S|re.M)
print("SUMMARIZE_SEARCH", bool(m))
if m:
print(m.group(0)[:1200])
print("HAS_SUMMARY_ASSIGNMENT", bool(re.search(r'summary\s*=\s*await\s*summarize_step', m.group(0))))
print("POST_SUMMARY_CONTEXT_CALL", bool(re.search(r'context\s*=\s*build_context', src.split(m.group(0)[-100:][0][-50:] if False else '',1)[1][:800])))
PYRepository: inesaranab/agent-harness Length of output: 14439 Do not promote generated memory to a system message.
🤖 Prompt for AI AgentsSource: MCP tools |
||
| for turn in turns: | ||
| context.extend(turn) # the most recent turns, verbatim | ||
| return context | ||
|
|
||
|
|
||
| # ── STATE: compress old turns into the running summary (an LLM call) ─ | ||
| async def summarize(old_turns: list, prior_summary: str) -> str: | ||
| transcript = "\n".join(_as_text(m) for turn in old_turns for m in turn)[:6000] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Do not prefix-truncate the transcript.
🤖 Prompt for AI Agents |
||
| resp = await client.responses.create( | ||
| model="gpt-5.6-luna", | ||
| input=[ | ||
| { | ||
| "role": "system", | ||
| "content": ( | ||
| "You compress an agent's work log into a short running summary. " | ||
| "Preserve concrete facts: item ids, categories, draft ids, amounts, " | ||
| "and what was already sent. Be terse." | ||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Avoid persisting sensitive details in compaction events. The summary is instructed to retain item IDs, draft IDs, amounts, and sent status. Runtime emits it in 🤖 Prompt for AI AgentsSource: MCP tools |
||
| ), | ||
| }, | ||
| { | ||
| "role": "user", | ||
| "content": ( | ||
| f"Prior summary:\n{prior_summary or '(none)'}\n\n" | ||
| f"Fold in this newer work:\n{transcript}\n\n" | ||
| "Return the updated summary." | ||
| ), | ||
| }, | ||
| ], | ||
| ) | ||
| return resp.output_text | ||
|
Comment on lines
+51
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^|/)harness/memory\.py$|^harness/|memory\.py' || true
echo "== outline harness/memory.py =="
if [ -f harness/memory.py ]; then
ast-grep outline harness/memory.py --view compact || true
echo "== relevant lines =="
nl -ba harness/memory.py | sed -n '1,130p'
fi
echo "== search MAX_CONTEXT_TOKENS and summary usage =="
rg -n "MAX_CONTEXT_TOKENS|summary|comp|compact|running summary|output_text|max_tokens|output_tokens|limit" -S . --glob '!**/.git/**' | head -200Repository: inesaranab/agent-harness Length of output: 587 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== OpenAI Responses API output_limits docs via web source =="
python3 - <<'PY'
import urllib.request
url="https://platform.openai.com/docs/api-reference/responses/create"
try:
req=urllib.request.Request(url, headers={"User-Agent":"CodeRabbit"})
with urllib.request.urlopen(req, timeout=20) as r:
data=r.read().decode("utf-8", "replace")
for term in ["max_output_tokens", "output_tokens", "response_format", "input_tokens"]:
print(f"\n--- occurrences of {term} ---")
hits=[i for i, line in enumerate(data.splitlines(),1) if term in line]
for i in hits[:15]:
print(f"{i}: {data.splitlines()[i-1]}")
except Exception as e:
print(f"ERROR: {e}")
PYRepository: inesaranab/agent-harness Length of output: 364 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== relevant memory.py lines =="
awk '{printf "%6d\t%s\n", NR, $0}' harness/memory.py | sed -n '1,120p'
echo "== relevant runtime/context lines =="
awk '{printf "%6d\t%s\n", NR, $0}' harness/runtime.py | sed -n '1,180p'
awk '{printf "%6d\t%s\n", NR, $0}' harness/memory.py | rg "MAX_CONTEXT_TOKENS|summary|prior_summary|transcript|len|token|output_text" -n || trueRepository: inesaranab/agent-harness Length of output: 11452 🌐 Web query:
💡 Result: The parameter Citations:
Enforce a hard bound on the running summary. “Be terse” is not a size guarantee. A verbose compaction response can make 🤖 Prompt for AI AgentsSource: MCP tools |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,15 +12,21 @@ | |
|
|
||
| from config import settings | ||
| from harness.bus import emit | ||
| from harness.system_prompt import SYSTEM_PROMPT | ||
| from harness.memory import ( | ||
| KEEP_CONTEXT_TOKENS, | ||
| MAX_CONTEXT_TOKENS, | ||
| build_context, | ||
| estimate_tokens, | ||
| summarize, | ||
| ) | ||
| from harness.tools import TOOL_SCHEMAS, run_tool | ||
|
|
||
| DBOS(config=DBOSConfig(name="ines-harness", system_database_url=settings.database_url)) | ||
|
|
||
| client = AsyncOpenAI(api_key=settings.openai_api_key) | ||
|
|
||
| # Loop guard | ||
| MAX_STEPS = 10 | ||
|
|
||
| MAX_STEPS = 30 | ||
|
|
||
|
|
||
| @DBOS.step() | ||
|
|
@@ -29,13 +35,18 @@ async def emit_step(event: dict) -> None: | |
|
|
||
|
|
||
| @DBOS.step() | ||
| async def model_turn(workflow_id: str, messages: list) -> dict: | ||
| async def summarize_step(old_turns: list, prior_summary: str) -> str: | ||
| return await summarize(old_turns, prior_summary) | ||
|
|
||
|
|
||
| @DBOS.step() | ||
| async def model_turn(workflow_id: str, context: list) -> dict: | ||
| tool_calls = [] | ||
|
|
||
| # Stream — we match on the SDK's event CLASSES, not strings. | ||
| # One model turn over the HYDRATED context (not the whole history). | ||
| async with client.responses.stream( | ||
| model="gpt-5.6-luna", | ||
| input=messages, | ||
| input=context, | ||
| tools=TOOL_SCHEMAS, | ||
| ) as stream: | ||
| async for event in stream: | ||
|
|
@@ -69,12 +80,9 @@ async def model_turn(workflow_id: str, messages: list) -> dict: | |
| "error": event.message, | ||
| } | ||
| ) | ||
| # Stop the turn instead of falling into get_final_response() | ||
| # on an already-errored stream. | ||
| raise RuntimeError(event.message) | ||
| final = await stream.get_final_response() | ||
|
|
||
| # Return only serializable data — DBOS checkpoints this to Postgres. | ||
| return { | ||
| "output": [ | ||
| item.model_dump(exclude={"status", "parsed_arguments"}) for item in final.output | ||
|
|
@@ -105,18 +113,40 @@ async def agent_workflow(user_input: str) -> str: | |
| workflow_id = DBOS.workflow_id | ||
| await emit_step({"type": "workflow.started", "workflowId": workflow_id, "input": user_input}) | ||
|
|
||
| # STATE | ||
| messages: list = [ | ||
| {"role": "system", "content": SYSTEM_PROMPT}, | ||
| {"role": "user", "content": user_input}, | ||
| ] | ||
| # Conversation as a list of TURNS to compact at clean boundaries. | ||
| turns: list = [] | ||
| summary = "" | ||
|
|
||
| # THE LOOP. | ||
| step = 0 | ||
| while step < MAX_STEPS: | ||
| turn = await model_turn(workflow_id, messages) # ty: ignore[invalid-argument-type] | ||
| # Append the model's output to history so the next turn sees it. | ||
| messages += turn["output"] | ||
| # 1. Compact: while the FULL assembled context (system prompt + task + | ||
| # summary + turns) is over budget, peel the oldest turns into the | ||
| # running summary (keeping at least the last turn). | ||
| if estimate_tokens(build_context(user_input, summary, turns)) > MAX_CONTEXT_TOKENS: | ||
| old: list = [] | ||
| while ( | ||
| len(turns) > 1 | ||
| and estimate_tokens(build_context(user_input, summary, turns)) > KEEP_CONTEXT_TOKENS | ||
| ): | ||
| old.append(turns.pop(0)) | ||
| if old: | ||
| summary = await summarize_step(old, summary) | ||
| context_tokens = estimate_tokens(build_context(user_input, summary, turns)) | ||
| await emit_step( | ||
| { | ||
| "type": "memory.compacted", | ||
| "workflowId": workflow_id, | ||
| "summarizedTurns": len(old), | ||
| "contextTokens": context_tokens, | ||
| "summary": summary, | ||
| } | ||
| ) | ||
|
|
||
| # 2 + 3. Hydrate the context and run one turn over it. | ||
| context = build_context(user_input, summary, turns) | ||
|
Comment on lines
+133
to
+146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Do not promote summarized tool output to system authority. Lines 167-172 retain arbitrary tool output; later, Line 132 passes it to the summarizer, and Also applies to: 167-175 🤖 Prompt for AI Agents |
||
| turn = await model_turn(workflow_id, context) # ty: ignore[invalid-argument-type] | ||
|
|
||
| turn_messages: list = list(turn["output"]) | ||
|
|
||
| # No tool calls means the model answered. We're done. | ||
| if not turn["tool_calls"]: | ||
|
|
@@ -132,17 +162,18 @@ async def agent_workflow(user_input: str) -> str: | |
| ) | ||
| return turn["output_text"] | ||
|
|
||
| # Run each requested tool with NO mediation, feed the result back. | ||
| # Run each requested tool, feed the result into THIS turn's messages. | ||
| for call in turn["tool_calls"]: | ||
| result = await tool_step(workflow_id, call) # ty: ignore[invalid-argument-type] | ||
| messages.append( | ||
| turn_messages.append( | ||
| { | ||
| "type": "function_call_output", | ||
| "call_id": call["call_id"], | ||
| "output": json.dumps(result), | ||
| } | ||
| ) | ||
|
|
||
| turns.append(turn_messages) | ||
| step += 1 | ||
|
|
||
| await emit_step( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.