Skip to content

Commit d78b0a5

Browse files
authored
Merge pull request #215 from zts212653/feat/issue-201-guidance-injection
feat(guidance): in-flight user guidance injection
2 parents 37e41bb + 1e32bbe commit d78b0a5

14 files changed

Lines changed: 612 additions & 4 deletions

File tree

‎client/main.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,9 @@ async def _read_control_stdin_line() -> str | None:
295295
ResourceAction.MCP_RELOAD,
296296
ResourceAction.MCP_SHOW_TOOL,
297297
ResourceAction.SKILLS_LIST,
298+
ResourceAction.GUIDE_INJECT,
299+
ResourceAction.GUIDE_LIST,
300+
ResourceAction.GUIDE_CLEAR,
298301
)
299302
_SESSION_RESUME_ACTION = "session:resume"
300303

‎dare_framework/agent/builder.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
from dare_framework.transport.interaction.dispatcher import ActionHandlerDispatcher
8484
from dare_framework.transport.kernel import AgentChannel
8585
from dare_framework.context.manage_context import ManageContextTool, MANAGE_CONTEXT_TOOL_NAME
86+
from dare_framework.guidance import GuidanceQueue
87+
from dare_framework.guidance.action_handler import GuidanceActionHandler
8688

8789
logger = logging.getLogger(__name__)
8890

@@ -359,11 +361,13 @@ async def build(self) -> TAgent:
359361
tools,
360362
self._tool_providers,
361363
)
364+
guidance_queue = GuidanceQueue()
362365
context = self._build_context(
363366
config=config,
364367
knowledge=knowledge,
365368
sys_prompt=sys_prompt,
366369
tool_gateway=tool_gateway,
370+
guidance_queue=guidance_queue,
367371
)
368372
self._context = context
369373
agent = self._build_impl(
@@ -385,6 +389,7 @@ async def build(self) -> TAgent:
385389
)
386390
action_dispatcher.register_action_handler(ToolsActionHandler(tool_manager))
387391
action_dispatcher.register_action_handler(ApprovalsActionHandler(approval_manager))
392+
action_dispatcher.register_action_handler(GuidanceActionHandler(guidance_queue))
388393
if skill_store is not None:
389394
action_dispatcher.register_action_handler(SkillsActionHandler(skill_store))
390395
action_dispatcher.register_action_handler(
@@ -567,6 +572,7 @@ def _build_context(
567572
knowledge: IKnowledge | None,
568573
sys_prompt: Prompt | None,
569574
tool_gateway: IToolGateway | None,
575+
guidance_queue: GuidanceQueue | None = None,
570576
) -> Context:
571577
"""Build context with shared defaults for all builder variants.
572578
@@ -586,6 +592,7 @@ def _build_context(
586592
tool_gateway=tool_gateway,
587593
assemble_context=self._assemble_context,
588594
context_window_tokens=self._context_window_tokens,
595+
guidance_queue=guidance_queue,
589596
)
590597
# 若 builder 已解析到 model:
591598
# - 默认(compression 为空或其他值)视为启用 "moving" 压缩;

‎dare_framework/context/context.py‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212

1313
if TYPE_CHECKING:
1414
from dare_framework.compression.moving_compression import MovingCompressor
15+
from dare_framework.guidance.guidance_queue import GuidanceQueue
1516
from dare_framework.model.types import Prompt
1617
from dare_framework.skill.types import Skill
1718

1819
from dare_framework.context.kernel import IContext, IRetrievalContext, IAssembleContext
19-
from dare_framework.context.types import AssembledContext, Budget, Message, MessageRole
20+
from dare_framework.context.types import AssembledContext, Budget, Message, MessageKind, MessageRole
2021

2122

2223
# ============================================================
@@ -45,6 +46,7 @@ def __init__(
4546
assemble_context: IAssembleContext | None = None,
4647
context_window_tokens: int | None = None,
4748
moving_compressor: MovingCompressor | None = None,
49+
guidance_queue: GuidanceQueue | None = None,
4850
) -> None:
4951
if config is None:
5052
raise ValueError("Context requires a non-null Config")
@@ -69,6 +71,9 @@ def __init__(
6971
# 可选:移动窗口压缩器,通过 moving_compressor.prune(self) 使用
7072
self._moving_compressor = moving_compressor
7173

74+
# 可选:用户引导队列,在 assemble() 时自动排空注入到 STM
75+
self._guidance_queue: GuidanceQueue | None = guidance_queue
76+
7277
# Initialize default short-term memory if not provided
7378
if self._short_term_memory is None:
7479
from dare_framework.memory.in_memory_stm import InMemorySTM
@@ -198,7 +203,26 @@ def list_tools(self) -> list[CapabilityDescriptor]:
198203

199204
# ========== Assembly Methods (Core) ==========
200205

206+
def _drain_guidance(self) -> None:
207+
"""Drain pending guidance items into STM.
208+
209+
Called before compression and assembly so that guidance messages
210+
participate in token-budget pruning and appear in the assembled
211+
context exactly once.
212+
"""
213+
if self._guidance_queue is None:
214+
return
215+
items = self._guidance_queue.drain_all_sync()
216+
for item in items:
217+
self.stm_add(Message(
218+
role=MessageRole.USER,
219+
kind=MessageKind.CHAT,
220+
text=item.content,
221+
metadata={"guidance_id": item.id, "source": "user_guidance"},
222+
))
223+
201224
def assemble(self) -> AssembledContext:
225+
self._drain_guidance()
202226
return self._assemble_context.assemble(self)
203227

204228
async def compress(self, **options: Any) -> None:
@@ -214,7 +238,13 @@ async def compress(self, **options: Any) -> None:
214238
await self._moving_compressor.prune(self, **prune_opts)
215239

216240
async def assemble_for_model(self, **options: Any) -> AssembledContext:
217-
"""供模型调用的装配入口:在内部静默触发压缩,然后返回 AssembledContext。"""
241+
"""供模型调用的装配入口:先排空引导队列,再压缩,最后装配。
242+
243+
Guidance is drained **before** compression so that guidance messages
244+
participate in token-budget pruning and do not silently push the
245+
final prompt over the configured context window.
246+
"""
247+
self._drain_guidance()
218248
await self.compress(**options)
219249
return self.assemble()
220250

‎dare_framework/context/smartcontext.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
if TYPE_CHECKING:
1212
from dare_framework.config.types import Config
13+
from dare_framework.guidance.guidance_queue import GuidanceQueue
1314
from dare_framework.model.types import Prompt
1415
from dare_framework.skill.types import Skill
1516
from dare_framework.tool.kernel import IToolGateway
@@ -38,6 +39,7 @@ def __init__(
3839
skill: Skill | None = None,
3940
assemble_context: IAssembleContext | None = None,
4041
context_window_tokens: int | None = None,
42+
guidance_queue: GuidanceQueue | None = None,
4143
) -> None:
4244
# SmartContext 默认使用带 id/mark 能力的 InMemorySmartSTM,
4345
# 仅当外部显式传入 short_term_memory 时才使用外部实现。
@@ -56,6 +58,7 @@ def __init__(
5658
skill=skill,
5759
assemble_context=assemble_context,
5860
context_window_tokens=context_window_tokens,
61+
guidance_queue=guidance_queue,
5962
)
6063

6164
# ========== Smart Extensions: Core / TaskComplete / STM Remove ==========
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Guidance injection — in-flight user guidance queue and action handler."""
2+
3+
from dare_framework.guidance.guidance_queue import GuidanceItem, GuidanceQueue
4+
5+
__all__ = ["GuidanceItem", "GuidanceQueue"]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Guidance-domain deterministic action handler."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from dare_framework.guidance.guidance_queue import GuidanceItem, GuidanceQueue
8+
from dare_framework.transport.interaction.handlers import IActionHandler
9+
from dare_framework.transport.interaction.resource_action import ResourceAction
10+
11+
12+
class GuidanceActionHandler(IActionHandler):
13+
"""Handle deterministic guidance-domain actions (inject / list / clear)."""
14+
15+
def __init__(self, guidance_queue: GuidanceQueue) -> None:
16+
self._queue = guidance_queue
17+
18+
def supports(self) -> set[ResourceAction]:
19+
return {
20+
ResourceAction.GUIDE_INJECT,
21+
ResourceAction.GUIDE_LIST,
22+
ResourceAction.GUIDE_CLEAR,
23+
}
24+
25+
# noinspection PyMethodOverriding
26+
async def invoke(
27+
self,
28+
action: ResourceAction,
29+
**params: Any,
30+
) -> Any:
31+
if action == ResourceAction.GUIDE_INJECT:
32+
content = params.get("content")
33+
if not content or not str(content).strip():
34+
raise ValueError("guide:inject requires a non-empty 'content' parameter")
35+
item = self._queue.enqueue(str(content))
36+
return {
37+
"id": item.id,
38+
"pending_count": self._queue.pending_count,
39+
}
40+
41+
if action == ResourceAction.GUIDE_LIST:
42+
items = self._queue.peek_all()
43+
return {
44+
"items": [_item_to_dict(i) for i in items],
45+
"count": len(items),
46+
}
47+
48+
if action == ResourceAction.GUIDE_CLEAR:
49+
removed = self._queue.clear()
50+
return {"removed": removed}
51+
52+
raise ValueError(f"unsupported guidance action: {action.value}")
53+
54+
55+
def _item_to_dict(item: GuidanceItem) -> dict[str, Any]:
56+
return {
57+
"id": item.id,
58+
"content": item.content,
59+
"created_at": item.created_at,
60+
}
61+
62+
63+
__all__ = ["GuidanceActionHandler"]
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Thread-safe guidance queue with consume-on-drain semantics."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
import time
7+
import uuid
8+
from collections import deque
9+
from dataclasses import dataclass
10+
11+
12+
@dataclass(frozen=True)
13+
class GuidanceItem:
14+
"""Single guidance entry enqueued by the user."""
15+
16+
id: str
17+
content: str
18+
created_at: float
19+
20+
21+
class GuidanceQueue:
22+
"""FIFO queue for in-flight user guidance messages.
23+
24+
The queue is written to by ``GuidanceActionHandler`` (via ``enqueue``) and
25+
drained by ``Context.assemble()`` (via ``drain_all_sync``). A
26+
``threading.Lock`` is used so that ``drain_all_sync`` can be called from
27+
the synchronous ``assemble()`` method.
28+
"""
29+
30+
def __init__(self, *, max_size: int = 100) -> None:
31+
self._items: deque[GuidanceItem] = deque()
32+
self._lock = threading.Lock()
33+
self._max_size = max_size
34+
35+
# ------------------------------------------------------------------
36+
# Write side (called from async action handler → GIL-safe)
37+
# ------------------------------------------------------------------
38+
39+
def enqueue(self, content: str) -> GuidanceItem:
40+
"""Append a guidance message. Returns the created item for auditing.
41+
42+
Raises ``ValueError`` if *content* is empty or whitespace-only, or if
43+
the queue has reached *max_size*.
44+
"""
45+
if not content or not content.strip():
46+
raise ValueError("guidance content must be a non-empty string")
47+
item = GuidanceItem(
48+
id=str(uuid.uuid4()),
49+
content=content,
50+
created_at=time.time(),
51+
)
52+
with self._lock:
53+
if len(self._items) >= self._max_size:
54+
raise RuntimeError(
55+
f"guidance queue full ({self._max_size} items); "
56+
"drain or clear before enqueuing more"
57+
)
58+
self._items.append(item)
59+
return item
60+
61+
# ------------------------------------------------------------------
62+
# Read / consume side
63+
# ------------------------------------------------------------------
64+
65+
def drain_all_sync(self) -> list[GuidanceItem]:
66+
"""Pop **all** pending items atomically.
67+
68+
This is the only consumption path — called from the synchronous
69+
``Context.assemble()`` method. After this call the queue is empty.
70+
"""
71+
with self._lock:
72+
items = list(self._items)
73+
self._items.clear()
74+
return items
75+
76+
def peek_all(self) -> list[GuidanceItem]:
77+
"""Return a snapshot of pending items without consuming them."""
78+
with self._lock:
79+
return list(self._items)
80+
81+
def clear(self) -> int:
82+
"""Remove all pending items. Returns the number removed."""
83+
with self._lock:
84+
count = len(self._items)
85+
self._items.clear()
86+
return count
87+
88+
@property
89+
def pending_count(self) -> int:
90+
"""Number of pending guidance items (lock-free; approximate)."""
91+
return len(self._items)
92+
93+
94+
__all__ = ["GuidanceItem", "GuidanceQueue"]

‎dare_framework/transport/interaction/resource_action.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class ResourceAction(StrEnum):
2424
MCP_SHOW_TOOL = "mcp:show-tool"
2525
SKILLS_LIST = "skills:list"
2626
MODEL_GET = "model:get"
27+
GUIDE_INJECT = "guide:inject"
28+
GUIDE_LIST = "guide:list"
29+
GUIDE_CLEAR = "guide:clear"
2730

2831
@classmethod
2932
def value_of(cls, raw: str) -> ResourceAction | None:

0 commit comments

Comments
 (0)