Stabilize live chat tool loop
This commit is contained in:
+25
-9
@@ -261,6 +261,7 @@ def create_app() -> FastAPI:
|
||||
memory_records=memory_records,
|
||||
skill_summary=await selected_skill_summary(body.message),
|
||||
reasoning=body.reasoning,
|
||||
reflect=bool(settings.enable_reflection),
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
@@ -454,8 +455,7 @@ def create_app() -> FastAPI:
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
"content": runtime.format_tool_observations_for_model(tool_observations),
|
||||
},
|
||||
]
|
||||
yield runtime_status(
|
||||
@@ -531,7 +531,13 @@ def create_app() -> FastAPI:
|
||||
"generation_stats": generation_stats.summary(),
|
||||
},
|
||||
)
|
||||
asyncio.create_task(runtime.complete_postprocessing(task.task_id, content))
|
||||
asyncio.create_task(
|
||||
runtime.complete_postprocessing(
|
||||
task.task_id,
|
||||
content,
|
||||
reflect=bool(settings.enable_reflection),
|
||||
)
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
@@ -707,8 +713,7 @@ def create_app() -> FastAPI:
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
"content": runtime.format_tool_observations_for_model(tool_observations),
|
||||
},
|
||||
]
|
||||
yield runtime_status(task_id, "answering", "Формирую ответ...")
|
||||
@@ -763,7 +768,13 @@ def create_app() -> FastAPI:
|
||||
"generation_stats": generation_stats.summary(),
|
||||
},
|
||||
)
|
||||
asyncio.create_task(runtime.complete_postprocessing(task_id, content))
|
||||
asyncio.create_task(
|
||||
runtime.complete_postprocessing(
|
||||
task_id,
|
||||
content,
|
||||
reflect=bool(settings.enable_reflection),
|
||||
)
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
@@ -878,8 +889,7 @@ def create_app() -> FastAPI:
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
"content": runtime.format_tool_observations_for_model(tool_observations),
|
||||
},
|
||||
]
|
||||
yield runtime_status(task_id, "answering", "Формирую ответ...")
|
||||
@@ -934,7 +944,13 @@ def create_app() -> FastAPI:
|
||||
"generation_stats": generation_stats.summary(),
|
||||
},
|
||||
)
|
||||
asyncio.create_task(runtime.complete_postprocessing(task_id, content))
|
||||
asyncio.create_task(
|
||||
runtime.complete_postprocessing(
|
||||
task_id,
|
||||
content,
|
||||
reflect=bool(settings.enable_reflection),
|
||||
)
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ class Settings:
|
||||
max_memory_tokens: int = 8000
|
||||
max_skill_tokens: int = 6000
|
||||
qdrant_url: str = "http://127.0.0.1:6333"
|
||||
enable_reflection: int = 0
|
||||
skip_live_llm_tests: int = 0
|
||||
|
||||
@property
|
||||
@@ -52,5 +53,6 @@ def get_settings() -> Settings:
|
||||
max_memory_tokens=int(os.getenv("DUCK_MAX_MEMORY_TOKENS", "8000")),
|
||||
max_skill_tokens=int(os.getenv("DUCK_MAX_SKILL_TOKENS", "6000")),
|
||||
qdrant_url=os.getenv("QDRANT_URL", "http://127.0.0.1:6333"),
|
||||
enable_reflection=int(os.getenv("DUCK_ENABLE_REFLECTION", "0")),
|
||||
skip_live_llm_tests=int(os.getenv("DUCK_SKIP_LIVE_LLM_TESTS", "0")),
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ from duck_core.tools.gateway import ToolGateway
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ACTION_DIRECTIVE_SCHEMA = load_json_schema("duck_core/schemas/action_directive.schema.json")
|
||||
MAX_TOOL_OBSERVATION_TEXT_CHARS = 2000
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -96,8 +97,7 @@ class RuntimeLoop:
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
"content": self.format_tool_observations_for_model(tool_observations),
|
||||
},
|
||||
]
|
||||
await self.event_store.append(
|
||||
@@ -232,8 +232,7 @@ class RuntimeLoop:
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
"content": self.format_tool_observations_for_model(tool_observations),
|
||||
},
|
||||
]
|
||||
await self.event_store.append(task_id, "model_call_started", {"role": "thinker"})
|
||||
@@ -394,9 +393,13 @@ class RuntimeLoop:
|
||||
"tool": tool_name,
|
||||
"reason": action.get("reason"),
|
||||
"decision": decision,
|
||||
"action": action,
|
||||
"result": result_payload,
|
||||
}
|
||||
|
||||
def _action_key(self, action: dict[str, Any]) -> str:
|
||||
return json.dumps(action, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
async def _approval_action_index(self, task_id: str, action: dict[str, Any]) -> int:
|
||||
events = await self.event_store.list_events(task_id)
|
||||
for event in reversed(events):
|
||||
@@ -413,6 +416,7 @@ class RuntimeLoop:
|
||||
messages: list[dict[str, str]],
|
||||
workspace: str | None,
|
||||
start_index: int = 1,
|
||||
seen_action_keys: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
await self.event_store.append(task_id, "model_call_started", {"role": "action"})
|
||||
@@ -443,6 +447,21 @@ class RuntimeLoop:
|
||||
{"index": index, "ok": False, "error": "Action must be an object"}
|
||||
)
|
||||
continue
|
||||
action_key = self._action_key(action)
|
||||
if seen_action_keys is not None and action_key in seen_action_keys:
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"tool_call_skipped",
|
||||
{
|
||||
"index": index,
|
||||
"tool": str(action.get("tool", "")),
|
||||
"reason": "duplicate_action",
|
||||
"action": action,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if seen_action_keys is not None:
|
||||
seen_action_keys.add(action_key)
|
||||
tool_name = str(action.get("tool", ""))
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
@@ -478,6 +497,7 @@ class RuntimeLoop:
|
||||
"index": index,
|
||||
"tool": tool_name,
|
||||
"reason": action.get("reason"),
|
||||
"action": action,
|
||||
"requires_approval": True,
|
||||
"approval_id": approval.approval_id if approval else None,
|
||||
"result": result_payload,
|
||||
@@ -494,11 +514,39 @@ class RuntimeLoop:
|
||||
"index": index,
|
||||
"tool": tool_name,
|
||||
"reason": action.get("reason"),
|
||||
"action": action,
|
||||
"result": result_payload,
|
||||
}
|
||||
)
|
||||
return observations
|
||||
|
||||
def format_tool_observations_for_model(self, observations: list[dict[str, Any]]) -> str:
|
||||
return "tool_observations:\n" + json.dumps(
|
||||
self.compact_tool_observations_for_model(observations),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def compact_tool_observations_for_model(
|
||||
self, observations: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
return [self._compact_observation_for_model(observation) for observation in observations]
|
||||
|
||||
def _compact_observation_for_model(self, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: self._compact_observation_for_model(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [self._compact_observation_for_model(item) for item in value]
|
||||
if isinstance(value, str) and len(value) > MAX_TOOL_OBSERVATION_TEXT_CHARS:
|
||||
half = MAX_TOOL_OBSERVATION_TEXT_CHARS // 2
|
||||
omitted = len(value) - MAX_TOOL_OBSERVATION_TEXT_CHARS
|
||||
return (
|
||||
value[:half]
|
||||
+ f"\n... [truncated {omitted} chars for model context] ...\n"
|
||||
+ value[-half:]
|
||||
)
|
||||
return value
|
||||
|
||||
async def _append_command_audit(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -538,14 +586,18 @@ class RuntimeLoop:
|
||||
initial_observations: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
all_observations = list(initial_observations or [])
|
||||
seen_action_keys = {
|
||||
self._action_key(observation["action"])
|
||||
for observation in all_observations
|
||||
if isinstance(observation.get("action"), dict)
|
||||
}
|
||||
current_messages = messages
|
||||
if all_observations:
|
||||
current_messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(all_observations, ensure_ascii=False, indent=2),
|
||||
"content": self.format_tool_observations_for_model(all_observations),
|
||||
},
|
||||
]
|
||||
for _ in range(self.max_tool_iterations):
|
||||
@@ -554,6 +606,7 @@ class RuntimeLoop:
|
||||
current_messages,
|
||||
workspace,
|
||||
start_index=len(all_observations) + 1,
|
||||
seen_action_keys=seen_action_keys,
|
||||
)
|
||||
if not observations:
|
||||
return all_observations
|
||||
@@ -564,8 +617,7 @@ class RuntimeLoop:
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(all_observations, ensure_ascii=False, indent=2),
|
||||
"content": self.format_tool_observations_for_model(all_observations),
|
||||
},
|
||||
]
|
||||
await self.event_store.append(
|
||||
|
||||
Reference in New Issue
Block a user