Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions harness/memory.py
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 || true

Repository: 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])))
PY

Repository: inesaranab/agent-harness

Length of output: 14439


Do not promote generated memory to a system message.

turns includes the model+tool-call pair plus function_call_output, so injected tool output can be summarized into summary and then reinserted as a new system message with full authority on the next turn. Keep compacted memory separate from trusted instructions: store it as delimited/untrusted data (for example a user/assistant-style memory message or clearly untrusted assistant/system block) and enforce dangerous tool permissions outside the model.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harness/memory.py` around lines 40 - 41, Change the summary injection in the
context-building flow so generated memory is not appended as a system message.
Store summary as clearly delimited, untrusted conversational memory (using the
existing user/assistant-style representation where available), while preserving
its compacted content and keeping dangerous tool authorization enforced outside
the model.

Source: 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not prefix-truncate the transcript.

old_turns is ordered oldest-to-newest, so [:6000] drops the newest work—the exact content the prompt says to fold in—before those turns are discarded. Build a token-bounded list of complete messages or summarize in chunks instead of slicing the flattened string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harness/memory.py` at line 49, Update the transcript construction in the
memory handling flow to avoid prefix-truncating the flattened old-turn text with
[:6000]. Preserve complete messages while enforcing the token limit,
prioritizing the newest turns/messages so the content being folded in is
retained; use chunked summarization if needed, then pass the bounded result
onward.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 memory.compacted, and the event bus persists event payloads to EventLog, creating a durable copy of potentially sensitive business data. Emit redacted metrics or protect detailed summaries with explicit access controls and retention limits. (raw.githubusercontent.com)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harness/memory.py` around lines 56 - 58, Update the compaction summary prompt
near the memory compaction logic so it does not retain item IDs, draft IDs,
amounts, or other sensitive business details in summaries emitted through
memory.compacted. Replace those details with redacted or aggregate metrics, and
ensure any necessary detailed summary is protected by explicit access controls
and retention limits before EventLog persistence.

Source: 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -200

Repository: 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}")
PY

Repository: 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 || true

Repository: inesaranab/agent-harness

Length of output: 11452


🌐 Web query:

OpenAI Responses API create max_output_tokens output_tokens_limit parameter

💡 Result:

The parameter max_output_tokens is the designated setting used in the OpenAI Responses API to control the maximum number of tokens generated in a single response [1][2]. Key details regarding this parameter include: Functionality: It sets a hard upper bound on the number of tokens the model can produce [1][2]. This limit encompasses both visible output tokens and any internal reasoning tokens generated by the model [1][3]. If the model reaches this limit, generation will stop, and the response will typically indicate a finish_reason of length [2]. Usage Context: * Responses API: You should use max_output_tokens for the newer Responses API [2][4]. * Chat Completions API: For the standard Chat Completions API, the equivalent parameter is max_completion_tokens [5][6]. While legacy models and some configurations may still support the older max_tokens parameter, max_completion_tokens is the modern, preferred standard for newer models, including o-series reasoning models [5][7][6]. Best Practices: * Accuracy: Because non-visible tokens (such as reasoning or structural tokens) count toward this limit, it is recommended to leave sufficient headroom when you require a specific amount of visible output [3]. * Consistency: Always verify which API endpoint (Responses vs. Chat Completions) you are utilizing, as they use different parameters to manage output limits [2][4]. There is no parameter named output_tokens_limit in the OpenAI API; max_output_tokens is the correct technical parameter name [1][8].

Citations:


Enforce a hard bound on the running summary.

“Be terse” is not a size guarantee. A verbose compaction response can make summary push build_context(...) over MAX_CONTEXT_TOKENS; runtime only compact-turns, so oversized compacted state cannot be recovered later. Set an explicit output limit, validate the returned token count, and fall back to prior_summary or retry when output is empty or oversized.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harness/memory.py` around lines 50 - 71, Update the response creation and
return flow in the memory compaction function to enforce MAX_CONTEXT_TOKENS: set
an explicit output token limit, count tokens in resp.output_text, and reject
empty or oversized summaries. When validation fails, retry compaction or safely
return prior_summary, ensuring the stored summary never exceeds the context
bound.

Source: MCP tools

71 changes: 51 additions & 20 deletions harness/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 build_context inserts the resulting text as a system message. An attacker controlling data returned by a permitted tool can persist instruction-like content and have it influence later turns with system priority, enabling model-mediated misuse of allowed tools. Keep historical summaries in an untrusted lower-priority message and explicitly frame historical/tool data as data, not instructions.

Also applies to: 167-175

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harness/runtime.py` around lines 132 - 145, Update the summarization and
context construction around summarize_step, build_context, and the historical
turns retained near the memory.compacted emission so summarized tool output is
never inserted as a system message. Place historical summaries in an untrusted
lower-priority role and explicitly frame all historical/tool content as data
rather than instructions, while preserving the existing compaction behavior.

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"]:
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"psycopg2-binary>=2.9.12",
"pydantic-settings>=2.14.2",
"sqlmodel>=0.0.39",
"tiktoken>=0.13.0",
"uvicorn[standard]>=0.51.0",
]

Expand Down
Loading
Loading