Add runtime guidance input queue

This commit is contained in:
wuyang6
2026-06-12 18:15:52 +08:00
parent 00a83ba4c3
commit 4ba8761db2
14 changed files with 1580 additions and 69 deletions
+69
View File
@@ -298,6 +298,7 @@ class LocalCodingAgent:
team_runtime: TeamRuntime | None = None
workflow_runtime: WorkflowRuntime | None = None
worktree_runtime: WorktreeRuntime | None = None
runtime_guidance_provider: Callable[[], tuple[dict[str, object], ...]] | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False)
@@ -734,6 +735,11 @@ class LocalCodingAgent:
return result
for turn_index in range(1, self.runtime_config.max_turns + 1):
self._inject_runtime_guidance(
session,
stream_events,
turn_index=turn_index,
)
self._microcompact_session_if_needed(
session,
stream_events,
@@ -4274,6 +4280,69 @@ class LocalCodingAgent:
transcript=session.transcript(),
)
def _inject_runtime_guidance(
self,
session: AgentSessionState,
stream_events: list[dict[str, object]],
*,
turn_index: int,
) -> None:
provider = self.runtime_guidance_provider
if provider is None:
return
try:
items = tuple(
item
for item in provider()
if isinstance(item, dict)
and str(item.get('content') or '').strip()
)
except Exception as exc: # noqa: BLE001
stream_events.append(
{
'type': 'runtime_guidance_error',
'turn_index': turn_index,
'error': str(exc)[:500],
}
)
return
if not items:
return
lines = [
'<runtime-guidance>',
(
'用户在本轮任务运行过程中追加了以下引导。请在不丢弃当前进展的前提下'
'吸收这些要求;如果它们与当前目标冲突,先说明冲突并选择最合理的继续方式。'
),
]
item_ids: list[int] = []
for index, item in enumerate(items, start=1):
item_id = item.get('id')
if isinstance(item_id, int) and not isinstance(item_id, bool):
item_ids.append(item_id)
content = str(item.get('content') or '').strip()
lines.append(f'{index}. {content}')
lines.append('</runtime-guidance>')
session.append_user(
'\n'.join(lines),
metadata={
'kind': 'runtime_guidance',
'turn_index': turn_index,
'queue_item_ids': item_ids,
},
message_id=f'runtime_guidance_{turn_index}_{uuid4().hex[:8]}',
display=False,
)
stream_events.append(
{
'type': 'runtime_guidance_injected',
'turn_index': turn_index,
'input_ids': item_ids,
'count': len(items),
'message': f'已将 {len(items)} 条用户引导注入当前任务',
}
)
def render_system_prompt(self) -> str:
prompt_context = self.build_prompt_context()
parts = self.build_system_prompt_parts(prompt_context)
+63
View File
@@ -230,6 +230,69 @@ class RunStateStore:
return None
return self._snapshot_from_row(conn, row)
def events_since(
self,
account_key: str,
session_id: str,
*,
after_event_seq: int = 0,
limit: int = 500,
) -> dict[str, Any]:
with self._connect() as conn:
rows = conn.execute(
"""
select e.id, e.recorded_at, e.event_json, s.run_id, s.status
from run_events e
join run_states s on s.run_id = e.run_id
where s.account_key = ?
and s.session_id = ?
and e.id > ?
order by e.id asc
limit ?
""",
(
account_key,
session_id,
max(0, int(after_event_seq)),
max(1, int(limit)),
),
).fetchall()
latest_row = conn.execute(
"""
select max(e.id) as latest_event_seq
from run_events e
join run_states s on s.run_id = e.run_id
where s.account_key = ?
and s.session_id = ?
""",
(account_key, session_id),
).fetchone()
events: list[dict[str, Any]] = []
for row in rows:
try:
event = json.loads(row['event_json'])
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(event, dict):
continue
events.append(
{
'event_seq': int(row['id']),
'run_id': row['run_id'],
'run_status': row['status'],
'recorded_at': float(row['recorded_at'] or 0.0),
'event': event,
}
)
latest_event_seq = 0
if latest_row is not None and latest_row['latest_event_seq'] is not None:
latest_event_seq = int(latest_row['latest_event_seq'])
return {
'session_id': session_id,
'latest_event_seq': latest_event_seq,
'events': events,
}
def mark_interrupted_if_active(
self,
run_id: str,
+511
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import hashlib
import sqlite3
import time
from dataclasses import asdict, dataclass
@@ -84,6 +85,12 @@ def save_agent_session(session: StoredAgentSession, directory: Path | None = Non
path = session_dir / 'session.json'
payload = asdict(session)
_write_agent_session_db_payload(target_dir, session.session_id, payload, path)
_sync_agent_display_messages(
target_dir,
session.session_id,
tuple(message for message in session.display_messages if isinstance(message, dict))
or tuple(message for message in session.messages if isinstance(message, dict)),
)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8')
return path
@@ -131,6 +138,266 @@ def list_agent_sessions(
return sorted(sessions.values(), key=lambda item: item[1], reverse=True)
def agent_session_delta(
session_id: str,
directory: Path | None = None,
*,
after_message_seq: int = 0,
limit: int = 500,
) -> dict[str, Any]:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
try:
stored = load_agent_session(session_id, directory=target_dir)
except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError):
stored = None
if stored is not None:
_sync_agent_display_messages(
target_dir,
session_id,
stored.display_messages or stored.messages,
)
with _connect_agent_session_db(target_dir) as conn:
latest_row = conn.execute(
"""
select max(seq) as latest_seq
from agent_display_messages
where session_id = ?
""",
(session_id,),
).fetchone()
rows = conn.execute(
"""
select seq, message_json, updated_at
from agent_display_messages
where session_id = ? and seq > ?
order by seq asc
limit ?
""",
(session_id, max(0, int(after_message_seq)), max(1, int(limit))),
).fetchall()
messages: list[dict[str, Any]] = []
for row in rows:
try:
message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError):
continue
if isinstance(message, dict):
messages.append(
{
'seq': int(row['seq']),
'updated_at': float(row['updated_at'] or 0.0),
'message': message,
}
)
latest_seq = 0
if latest_row is not None and latest_row['latest_seq'] is not None:
latest_seq = int(latest_row['latest_seq'])
return {
'session_id': session_id,
'latest_message_seq': latest_seq,
'messages': messages,
'session': (
{
'session_id': stored.session_id,
'turns': stored.turns,
'tool_calls': stored.tool_calls,
'usage': stored.usage,
'total_cost_usd': stored.total_cost_usd,
'model': stored.model_config.get('model'),
'is_training': stored.is_training,
'session_metadata': stored.session_metadata or {},
}
if stored is not None
else {'session_id': session_id}
),
}
def enqueue_session_input(
session_id: str,
*,
content: str,
directory: Path | None = None,
run_id: str | None = None,
kind: str = 'next_turn',
status: str = 'pending',
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
now = time.time()
normalized_kind = kind if kind in {'next_turn', 'guidance'} else 'next_turn'
with _connect_agent_session_db(target_dir) as conn:
cursor = conn.execute(
"""
insert into agent_input_queue (
session_id, run_id, kind, status, content,
created_at, updated_at, metadata_json
)
values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id,
run_id or '',
normalized_kind,
status,
content,
now,
now,
json.dumps(metadata or {}, ensure_ascii=False, sort_keys=True),
),
)
item_id = int(cursor.lastrowid)
return {
'id': item_id,
'session_id': session_id,
'run_id': run_id or '',
'kind': normalized_kind,
'status': status,
'content': content,
'created_at': now,
'updated_at': now,
'metadata': metadata or {},
}
def list_session_input_queue(
session_id: str,
directory: Path | None = None,
*,
statuses: tuple[str, ...] = ('pending',),
limit: int = 100,
) -> list[dict[str, Any]]:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
placeholders = ','.join('?' for _ in statuses)
with _connect_agent_session_db(target_dir) as conn:
rows = conn.execute(
f"""
select *
from agent_input_queue
where session_id = ?
and status in ({placeholders})
order by id asc
limit ?
""",
(session_id, *statuses, max(1, int(limit))),
).fetchall()
return [_queue_row_to_dict(row) for row in rows]
def update_session_input_queue_item(
session_id: str,
item_id: int,
directory: Path | None = None,
*,
content: str | None = None,
kind: str | None = None,
status: str | None = None,
run_id: str | None = None,
) -> dict[str, Any] | None:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
assignments: list[str] = ['updated_at = ?']
values: list[Any] = [time.time()]
if content is not None:
assignments.append('content = ?')
values.append(content)
if kind is not None:
assignments.append('kind = ?')
values.append(kind if kind in {'next_turn', 'guidance'} else 'next_turn')
if status is not None:
assignments.append('status = ?')
values.append(status)
if status in {'consumed', 'cancelled'}:
assignments.append('consumed_at = coalesce(consumed_at, ?)')
values.append(time.time())
if run_id is not None:
assignments.append('run_id = ?')
values.append(run_id)
values.extend([session_id, int(item_id)])
with _connect_agent_session_db(target_dir) as conn:
conn.execute(
f"""
update agent_input_queue
set {', '.join(assignments)}
where session_id = ? and id = ?
""",
values,
)
row = conn.execute(
"""
select *
from agent_input_queue
where session_id = ? and id = ?
""",
(session_id, int(item_id)),
).fetchone()
return _queue_row_to_dict(row) if row is not None else None
def cancel_session_input_queue(
session_id: str,
directory: Path | None = None,
*,
run_id: str | None = None,
) -> int:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
now = time.time()
where = 'session_id = ? and status = ?'
values: list[Any] = [session_id, 'pending']
if run_id:
where += ' and (run_id = ? or run_id = ?)'
values.extend([run_id, ''])
with _connect_agent_session_db(target_dir) as conn:
cursor = conn.execute(
f"""
update agent_input_queue
set status = 'cancelled',
updated_at = ?,
consumed_at = coalesce(consumed_at, ?)
where {where}
""",
(now, now, *values),
)
return int(cursor.rowcount or 0)
def consume_session_guidance(
session_id: str,
run_id: str,
directory: Path | None = None,
*,
limit: int = 10,
) -> list[dict[str, Any]]:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
now = time.time()
with _connect_agent_session_db(target_dir) as conn:
rows = conn.execute(
"""
select *
from agent_input_queue
where session_id = ?
and status = 'pending'
and kind = 'guidance'
and (run_id = ? or run_id = '')
order by id asc
limit ?
""",
(session_id, run_id, max(1, int(limit))),
).fetchall()
ids = [int(row['id']) for row in rows]
if ids:
conn.execute(
f"""
update agent_input_queue
set status = 'consumed',
updated_at = ?,
consumed_at = ?
where id in ({','.join('?' for _ in ids)})
""",
(now, now, *ids),
)
return [_queue_row_to_dict(row) for row in rows]
def delete_agent_session(session_id: str, directory: Path | None = None) -> bool:
target_dir = directory or DEFAULT_AGENT_SESSION_DIR
deleted = False
@@ -140,6 +407,14 @@ def delete_agent_session(session_id: str, directory: Path | None = None) -> bool
(session_id,),
)
deleted = cursor.rowcount > 0
conn.execute(
'delete from agent_display_messages where session_id = ?',
(session_id,),
)
conn.execute(
'delete from agent_input_queue where session_id = ?',
(session_id,),
)
nested = target_dir / session_id
if nested.exists():
import shutil
@@ -288,6 +563,195 @@ def _write_agent_session_revision(
)
def _sync_agent_display_messages(
directory: Path,
session_id: str,
display_messages: tuple[JSONDict, ...],
) -> None:
if not display_messages:
return
now = time.time()
with _connect_agent_session_db(directory) as conn:
existing_rows = conn.execute(
"""
select seq, message_key, message_json
from agent_display_messages
where session_id = ?
""",
(session_id,),
).fetchall()
existing: dict[int, sqlite3.Row] = {
int(row['seq']): row for row in existing_rows
}
existing_by_key: dict[str, sqlite3.Row] = {
str(row['message_key'] or ''): row
for row in existing_rows
if str(row['message_key'] or '')
}
next_seq = max(existing.keys(), default=0) + 1
for index, message in enumerate(display_messages, start=1):
if not isinstance(message, dict):
continue
message_json = json.dumps(message, ensure_ascii=False, sort_keys=True)
message_key = _display_message_key(message)
row = existing.get(index)
keyed_row = existing_by_key.get(message_key)
if keyed_row is not None and keyed_row['message_json'] == message_json:
continue
if keyed_row is not None:
try:
keyed_old_message = json.loads(keyed_row['message_json'])
except (TypeError, json.JSONDecodeError):
keyed_old_message = {}
if not isinstance(keyed_old_message, dict):
keyed_old_message = {}
if _is_replaceable_display_message(keyed_old_message):
conn.execute(
"""
update agent_display_messages
set role = ?,
updated_at = ?,
message_json = ?
where session_id = ? and seq = ?
""",
(
str(message.get('role') or ''),
now,
message_json,
session_id,
int(keyed_row['seq']),
),
)
continue
if row is None:
conn.execute(
"""
insert into agent_display_messages (
session_id, seq, message_key, role, updated_at, message_json
)
values (?, ?, ?, ?, ?, ?)
""",
(
session_id,
index,
message_key,
str(message.get('role') or ''),
now,
message_json,
),
)
continue
if row['message_json'] == message_json:
continue
try:
old_message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError):
old_message = {}
if not isinstance(old_message, dict):
old_message = {}
old_key = str(row['message_key'] or '')
should_update = (
old_key == message_key
or _is_replaceable_display_message(old_message)
or _same_role_and_content(old_message, message)
)
if not should_update:
append_seq = next_seq
next_seq += 1
conn.execute(
"""
insert into agent_display_messages (
session_id, seq, message_key, role, updated_at, message_json
)
values (?, ?, ?, ?, ?, ?)
""",
(
session_id,
append_seq,
message_key,
str(message.get('role') or ''),
now,
message_json,
),
)
existing_by_key[message_key] = {
'seq': append_seq,
'message_key': message_key,
'message_json': message_json,
} # type: ignore[assignment]
continue
conn.execute(
"""
update agent_display_messages
set message_key = ?,
role = ?,
updated_at = ?,
message_json = ?
where session_id = ? and seq = ?
""",
(
message_key,
str(message.get('role') or ''),
now,
message_json,
session_id,
index,
),
)
def _display_message_key(message: JSONDict) -> str:
message_id = message.get('message_id')
if isinstance(message_id, str) and message_id:
return f'id:{message_id}'
tool_call_id = message.get('tool_call_id')
if isinstance(tool_call_id, str) and tool_call_id:
return f'tool:{tool_call_id}'
role = str(message.get('role') or '')
content = '' if message.get('content') is None else str(message.get('content') or '')
digest = hashlib.sha1(f'{role}\0{content}'.encode('utf-8')).hexdigest()
return f'hash:{digest}'
def _is_replaceable_display_message(message: JSONDict) -> bool:
metadata = message.get('metadata')
if not isinstance(metadata, dict):
return False
if metadata.get('placeholder') is True:
return True
return metadata.get('kind') in {'run_status'}
def _same_role_and_content(left: JSONDict, right: JSONDict) -> bool:
return (
str(left.get('role') or '') == str(right.get('role') or '')
and str(left.get('content') or '') == str(right.get('content') or '')
)
def _queue_row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
try:
metadata = json.loads(row['metadata_json'] or '{}')
except (TypeError, json.JSONDecodeError):
metadata = {}
return {
'id': int(row['id']),
'session_id': str(row['session_id'] or ''),
'run_id': str(row['run_id'] or ''),
'kind': str(row['kind'] or ''),
'status': str(row['status'] or ''),
'content': str(row['content'] or ''),
'created_at': float(row['created_at'] or 0.0),
'updated_at': float(row['updated_at'] or 0.0),
'consumed_at': (
float(row['consumed_at'])
if row['consumed_at'] is not None
else None
),
'metadata': metadata if isinstance(metadata, dict) else {},
}
def _read_agent_session_db_payload(
directory: Path,
session_id: str,
@@ -365,6 +829,53 @@ def _connect_agent_session_db(directory: Path) -> sqlite3.Connection:
on agent_session_revisions(session_id, revision_id)
"""
)
conn.execute(
"""
create table if not exists agent_display_messages (
session_id text not null,
seq integer not null,
message_key text not null,
role text default '',
updated_at real not null,
message_json text not null,
primary key(session_id, seq)
)
"""
)
conn.execute(
"""
create index if not exists idx_agent_display_messages_session
on agent_display_messages(session_id, seq)
"""
)
conn.execute(
"""
create table if not exists agent_input_queue (
id integer primary key autoincrement,
session_id text not null,
run_id text default '',
kind text not null,
status text not null,
content text not null,
created_at real not null,
updated_at real not null,
consumed_at real,
metadata_json text default '{}'
)
"""
)
conn.execute(
"""
create index if not exists idx_agent_input_queue_session
on agent_input_queue(session_id, status, id)
"""
)
conn.execute(
"""
create index if not exists idx_agent_input_queue_run
on agent_input_queue(session_id, run_id, status, id)
"""
)
return conn