power-loop 6.46.4__tar.gz → 6.48.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {power_loop-6.46.4 → power_loop-6.48.0}/PKG-INFO +1 -1
  2. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/__init__.py +1 -1
  3. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/agent/stateful_loop.py +7 -3
  4. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/agent/types.py +3 -7
  5. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/pipeline.py +62 -192
  6. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/representation.py +7 -0
  7. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/default_manifest.py +3 -1
  8. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/default_tools.py +46 -20
  9. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/engine.py +147 -3
  10. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/resume.py +98 -20
  11. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/runner.py +15 -0
  12. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop.egg-info/PKG-INFO +1 -1
  13. {power_loop-6.46.4 → power_loop-6.48.0}/LICENSE +0 -0
  14. {power_loop-6.46.4 → power_loop-6.48.0}/README.md +0 -0
  15. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/__init__.py +0 -0
  16. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  17. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  18. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  19. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  20. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  21. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  22. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  23. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  24. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/_vendor/llm_client/proxy_fallback.py +0 -0
  25. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/agent/__init__.py +0 -0
  26. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/agent/follow_up.py +0 -0
  27. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/agent/sink.py +0 -0
  28. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/agent/system_prompt.py +0 -0
  29. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/__init__.py +0 -0
  30. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/errors.py +0 -0
  31. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/event_payloads.py +0 -0
  32. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/events.py +0 -0
  33. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/handlers.py +0 -0
  34. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/hook_contexts.py +0 -0
  35. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/hooks.py +0 -0
  36. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/messages.py +0 -0
  37. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/protocols.py +0 -0
  38. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contracts/tools.py +0 -0
  39. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/__init__.py +0 -0
  40. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/_redact.py +0 -0
  41. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/jsonl_sink.py +0 -0
  42. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/logging_sink.py +0 -0
  43. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/mcp.py +0 -0
  44. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/metrics_sink.py +0 -0
  45. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/contrib/otel_sink.py +0 -0
  46. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/agent_context.py +0 -0
  47. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/events.py +0 -0
  48. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/hooks.py +0 -0
  49. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/phase.py +0 -0
  50. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/runner.py +0 -0
  51. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/core/state.py +0 -0
  52. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/py.typed +0 -0
  53. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/blackboard.py +0 -0
  54. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/budget.py +0 -0
  55. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/cancellation.py +0 -0
  56. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/capability_probe.py +0 -0
  57. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/compact.py +0 -0
  58. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/env.py +0 -0
  59. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/exec_backend.py +0 -0
  60. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/fold.py +0 -0
  61. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/fold_adapter.py +0 -0
  62. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/history_projector.py +0 -0
  63. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/history_sanitize.py +0 -0
  64. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/human_input.py +0 -0
  65. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/image_recall.py +0 -0
  66. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/memory.py +0 -0
  67. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/notes.py +0 -0
  68. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/provider.py +0 -0
  69. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/retry.py +0 -0
  70. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/runtime_state.py +0 -0
  71. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/session_store.py +0 -0
  72. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/skills.py +0 -0
  73. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/spec.py +0 -0
  74. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/steer.py +0 -0
  75. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/stop_policy.py +0 -0
  76. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/__init__.py +0 -0
  77. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  78. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  79. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  80. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  81. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/capabilities.py +0 -0
  82. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/db.py +0 -0
  83. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/dialect.py +0 -0
  84. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/factory.py +0 -0
  85. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/schema.py +0 -0
  86. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/store.py +0 -0
  87. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/store/types.py +0 -0
  88. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/structured.py +0 -0
  89. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/stub_provider.py +0 -0
  90. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/timers.py +0 -0
  91. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/todos.py +0 -0
  92. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/runtime/usage_estimate.py +0 -0
  93. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/__init__.py +0 -0
  94. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/blackboard.py +0 -0
  95. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/command_policy.py +0 -0
  96. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/command_rules.py +0 -0
  97. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/registry.py +0 -0
  98. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/tools/spawn_agent.py +0 -0
  99. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/__init__.py +0 -0
  100. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/api.py +0 -0
  101. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/introspect.py +0 -0
  102. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/journal.py +0 -0
  103. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/result.py +0 -0
  104. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/spec.py +0 -0
  105. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/subprocess_executor.py +0 -0
  106. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/tool.py +0 -0
  107. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop/workflow/worker.py +0 -0
  108. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop.egg-info/SOURCES.txt +0 -0
  109. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop.egg-info/dependency_links.txt +0 -0
  110. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop.egg-info/requires.txt +0 -0
  111. {power_loop-6.46.4 → power_loop-6.48.0}/power_loop.egg-info/top_level.txt +0 -0
  112. {power_loop-6.46.4 → power_loop-6.48.0}/pyproject.toml +0 -0
  113. {power_loop-6.46.4 → power_loop-6.48.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 6.46.4
3
+ Version: 6.48.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "6.46.4"
18
+ __version__ = "6.48.0"
19
19
 
20
20
  # Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
21
21
  # a custom LLMService) don't reach into the internal vendored transport package (H3.4).
@@ -785,6 +785,7 @@ class StatefulAgentLoop:
785
785
  tools: Sequence[str] | ToolRegistry | None = None,
786
786
  system_prompt: str | None = None,
787
787
  max_rounds: int | None = None,
788
+ enqueue_only: bool = False,
788
789
  ) -> StatefulResult | FollowUpQueued:
789
790
  """Put ``items`` in front of the session's model — now, or as soon as it can take them.
790
791
 
@@ -798,6 +799,8 @@ class StatefulAgentLoop:
798
799
  them. Returns :class:`FollowUpQueued`.
799
800
  * idle → a fresh send runs with the waiting items as its input (everything pending —
800
801
  including items a cancelled run left behind — oldest first). Returns its result.
802
+ * ``enqueue_only=True`` → persist and return without starting an idle send;
803
+ the orchestration owner must drain it, including the terminal window.
801
804
  * nothing new (all duplicates) → :class:`FollowUpQueued` with ``accepted=0``.
802
805
 
803
806
  ``stop_event`` / ``tools`` / ``system_prompt`` / ``max_rounds`` apply only to a run this
@@ -824,7 +827,7 @@ class StatefulAgentLoop:
824
827
  duplicates=len(rows) - len(accepted), accepted_ids=tuple(accepted),
825
828
  )
826
829
 
827
- if not accepted or self._lock_for(sid).locked():
830
+ if enqueue_only or not accepted or self._lock_for(sid).locked():
828
831
  return await _queued()
829
832
  if self.config.distributed_sessions:
830
833
  # Idle HERE, but another process may be driving this session: its run claims the
@@ -847,6 +850,7 @@ class StatefulAgentLoop:
847
850
  tools: Sequence[str] | ToolRegistry | None = None,
848
851
  system_prompt: str | None = None,
849
852
  max_rounds: int | None = None,
853
+ enqueue_only: bool = False,
850
854
  kind: str = INBOX_KIND_USER,
851
855
  mode: str = "queue",
852
856
  item_id: str | None = None,
@@ -865,7 +869,7 @@ class StatefulAgentLoop:
865
869
  return await self.deliver(
866
870
  InboxItem(content=user_input, kind=kind, mode=mode, item_id=item_id),
867
871
  session_id, stop_event=stop_event, tools=tools, system_prompt=system_prompt,
868
- max_rounds=max_rounds,
872
+ max_rounds=max_rounds, enqueue_only=enqueue_only,
869
873
  )
870
874
 
871
875
  def pending_follow_up_count(self, session_id: str) -> int:
@@ -1268,7 +1272,7 @@ class StatefulAgentLoop:
1268
1272
  jobs.append({"id": "send", "kind": "foreground", "title": "当前这一轮",
1269
1273
  "status": "running", "started_at": None, "origin": ""})
1270
1274
  for t in await store.list_background_tasks(session_id):
1271
- if t.status != "running":
1275
+ if t.status not in ("queued", "running"):
1272
1276
  continue
1273
1277
  jobs.append({"id": f"bg:{t.task_id}", "kind": "background",
1274
1278
  "title": str(t.command or "")[:80], "status": t.status,
@@ -106,14 +106,10 @@ class AgentLoopConfig:
106
106
  #: 真实大小)≥ 它 → 以 ``context_checkpoint`` 优雅收尾本 send(COMPLETE_DECIDE 先给宿主收尾窗口)
107
107
  #: → 正常投影 → 宿主的续接机制在新 send 里接着干。与 ``max_tokens_per_run``(累计费用上限)正交。
108
108
  context_checkpoint_tokens: int | None = None
109
- #: ``insend_distill_tokens``:send 内保险丝——**本 send 内新增**的上下文 ≥ 它时(6.27.0 起量的是
110
- #: 增量:上一轮真实 prompt_tokens 减去本 send 第一轮的 prompt_tokens;拿不到真实用量就估算本 send
111
- #: 新增的行),把当前 send 里热尾之外的冷工具结果**在内存里替换成它的投影行**(同一套
112
- #: ToolDefinition.project 蒸馏 + ``recall_send`` 坐标),不落盘、不改 pl_messages。None → 关。
113
- #: 6.27.0 之前量的是整个上下文(系统提示词 + 历史 + 本 send),一开局就可能超阈值、每轮都烧。
109
+ #: Total prompt threshold for in-send semantic distillation. When enabled,
110
+ #: context_checkpoint_tokens is ignored: fuse and cut are exclusive policies.
114
111
  insend_distill_tokens: int | None = None
115
- #: 每次触发只蒸馏**最早**的 n 条尚未蒸馏的工具结果(逐轮递进:下一轮仍超阈值就再蒸馏下 n 条),
116
- #: 且永远不动最近 ``hot_tail`` 条。触发依据优先用上一轮供应商返回的真实 prompt_tokens。
112
+ #: Deprecated compatibility field; does not limit the number of distilled calls.
117
113
  insend_distill_batch: int = 10
118
114
  insend_distill_hot_tail: int = 8
119
115
  #: 6.27.0 这些工具的**调用参数永不瘦身**(结果照旧)。给「模型自己说的话」用:send_message /
@@ -882,76 +882,6 @@ class AgentPipeline:
882
882
 
883
883
  _DISTILL_MARK = "[distilled #"
884
884
 
885
- #: 参数被瘦身后留在原位的标记(值本身仍是合法 JSON 字符串,工具早已执行完,
886
- #: 这里只影响模型回看时读到什么)。
887
- _ARGS_MARK = "⟨已移出上下文"
888
- #: 单个参数字段超过它才瘦身:短字段(路径、开关、行号)留着才看得出这次调用干了什么。
889
- _ARGS_KEEP_CHARS = 200
890
-
891
- def _has_heavy_args(self, idx: int) -> bool:
892
- """这一行是 assistant,且它的某个 tool_calls 参数里有还没瘦过的大字段。"""
893
- m = self.history[idx]
894
- if m.get("role") != "assistant":
895
- return False
896
- keep = set(getattr(self.config, "insend_distill_keep_tools", ()) or ())
897
- for tc in m.get("tool_calls") or []:
898
- fn = (tc or {}).get("function") or {}
899
- if str(fn.get("name") or "") in keep:
900
- continue
901
- raw = fn.get("arguments")
902
- if (
903
- isinstance(raw, str)
904
- and len(raw) > 300
905
- and self._ARGS_MARK not in raw
906
- ):
907
- return True
908
- return False
909
-
910
- def _distill_tool_call_args(self, idx: int, seqs: list[Any]) -> int:
911
- """把一行 assistant 的 tool_calls 参数里的大字段换成指针,**保持合法 JSON**。
912
-
913
- 为什么不套用投影那套逐工具规则:那边产出的是给人读的一行散文,而这里改的是
914
- 供应商要按 JSON 解析的 ``function.arguments``。所以规则通用——短字段原样留下
915
- (路径、模式、行号这些才看得出这次调用干了什么),只有超过 ``_ARGS_KEEP_CHARS``
916
- 的长字符串换成一句带 ``recall_send`` 坐标的说明。工具早已执行完,改它不影响任何
917
- 副作用,只影响模型回看时读到什么。返回释放的估算 token。"""
918
- m = self.history[idx]
919
- seq = seqs[idx] if idx < len(seqs) else None
920
- coord = (
921
- f"recall_send(send_index={self.send_index}, seq={seq})"
922
- if (self.send_index is not None and seq is not None)
923
- else "recall_send"
924
- )
925
- freed = 0
926
- keep = set(getattr(self.config, "insend_distill_keep_tools", ()) or ())
927
- for tc in m.get("tool_calls") or []:
928
- fn = (tc or {}).get("function") or {}
929
- if str(fn.get("name") or "") in keep:
930
- continue # 模型自己的发言(send_message 之类):瘦成占位符它会照样再发一遍
931
- raw = fn.get("arguments")
932
- if not isinstance(raw, str) or len(raw) <= 300 or self._ARGS_MARK in raw:
933
- continue
934
- try:
935
- args = json.loads(raw)
936
- except (TypeError, ValueError):
937
- continue
938
- if not isinstance(args, dict):
939
- continue
940
- slim = {
941
- k: (
942
- f"{self._ARGS_MARK} {len(v)} 字符,需要时 {coord} 回取⟩"
943
- if isinstance(v, str) and len(v) > self._ARGS_KEEP_CHARS
944
- else v
945
- )
946
- for k, v in args.items()
947
- }
948
- new_raw = json.dumps(slim, ensure_ascii=False)
949
- if len(new_raw) >= len(raw):
950
- continue # 没省下就别动(改了反而打断前缀缓存)
951
- fn["arguments"] = new_raw
952
- freed += (len(raw) - len(new_raw)) // 4
953
- return freed
954
-
955
885
  def _context_profile(self, start: int, limit: int = 4) -> str:
956
886
  """本 send 的上下文被谁占着——`工具名:来源=字符数`,最大的几个。
957
887
 
@@ -978,106 +908,65 @@ class AgentPipeline:
978
908
  top = sorted(buckets.items(), key=lambda kv: -kv[1])[:limit]
979
909
  return " ".join(f"{k}={v // 1000}K" for k, v in top if v >= 1000) or "(无大项)"
980
910
 
981
- def _distill_oldest_tool_rows(self, batch: int, hot_tail: int) -> int:
982
- """把当前 send 里**最早的 batch 条**工具占用在内存里换成指针(6.10.0;6.25.0 起含调用参数)。
911
+ def _distill_oldest_tool_rows(self, batch: int, hot_tail: int, *, target_tokens: int | None = None) -> int:
912
+ """Distill complete calls oldest first; ``batch`` is ignored for compatibility.
983
913
 
984
- 「工具占用」有两块:结果在 tool 行、**参数在 assistant 行的 tool_calls 里**。两块按位置
985
- 统一排队、一起算进 batch,最早的先回收。
986
-
987
- 投影行 = 与 end-of-send 投影**同一套** ``ToolDefinition.project`` 蒸馏(找不到钩子就截断),
988
- 带 ``recall_send(send_index, seq)`` 坐标——原文永远在 pl_messages,要用就回取。不落盘、不改
989
- 数据库、不改行数(sink 的 index↔seq 对齐不受影响)。最近 ``hot_tail`` 条永不动。verbatim
990
- 模式不做(那边有就地压缩器)。返回本批释放的估算 token(≈字符/4)。"""
914
+ Protect invocation IDs, not result rows: parallel results may arrive out of order.
915
+ Only replace in-memory copies, keeping persisted audit records and seq alignment intact.
916
+ """
991
917
  rep = self.config.representation
992
- if getattr(rep, "kind", "projection") == "verbatim":
918
+ distill = getattr(rep, "distill_tool_call", None)
919
+ if getattr(rep, "kind", "projection") == "verbatim" or distill is None:
993
920
  return 0
994
- project_tool = getattr(rep, "_project_tool", None)
995
921
  seqs = getattr(self.sink, "_history_seqs", None) or []
996
- # 6.27.0 只动**本 send** 追加的行:run 之前就在历史里的(上一个 send 留下的逐字行)归跨 send
997
- # 投影管,保险丝碰它们等于两套机制抢同一段历史。
998
922
  start = min(int(getattr(self, "_send_start_len", 0) or 0), len(self.history))
999
- tool_idx = [
1000
- i for i, m in enumerate(self.history)
1001
- if i >= start and m.get("role") == "tool" and isinstance(m.get("content"), str)
1002
- ]
1003
- # max(0, …):本 send 的工具行少于 hot_tail 时,切片起点会是负数,Python 把它当成
1004
- # 「倒数第 |x| 条」绕回去——5 条工具行配 hot_tail=8 时只保住最后 3 条,最早 2 条
1005
- # 照样被蒸馏,而那 2 条正是模型手边的东西。本意是「不足 hot_tail 条就全保住」:
1006
- # 保险丝的前提是有旧结果可回收,没有旧的就什么都不该动,让它涨到切 send 阈值
1007
- # 优雅收尾即可(断片比切 send 贵得多)。
1008
- protected = (
1009
- set(tool_idx[max(0, len(tool_idx) - hot_tail):]) if hot_tail > 0 else set()
1010
- )
1011
- # 热尾的起点:它之后的任何一行(工具结果、assistant 的调用参数)都算「手边的东西」。
1012
- protect_from = min(protected) if protected else len(self.history)
1013
- result_ok = {
1014
- i for i in tool_idx
1015
- if i not in protected
1016
- and not str(self.history[i].get("content")).startswith(self._DISTILL_MARK)
1017
- and len(self.history[i].get("content") or "") > 300
1018
- }
1019
- # 工具在上下文里占两块:结果在 tool 行,**参数在 assistant 行的 tool_calls 里**。
1020
- # 参数从来没被保险丝碰过,而实测它占全库上下文的 29%——write_file 一个人 12.6MB,
1021
- # 单次最大 5.4 万字符:文件已经落盘了,正文却还在 prompt 里一轮轮重发。
1022
- # 跨 send 的投影早就把两块一起收掉了,缺的只是 send 内这一层。
1023
- candidates: list[tuple[str, int]] = sorted(
1024
- [("result", i) for i in result_ok]
1025
- + [("args", i) for i in range(start, protect_from) if self._has_heavy_args(i)],
1026
- key=lambda x: x[1],
1027
- )
1028
- picked_all = candidates[: max(1, batch)]
1029
- picked = [i for kind, i in picked_all if kind == "result"]
1030
- picked_args = [i for kind, i in picked_all if kind == "args"]
1031
- if not picked_all:
1032
- return 0
1033
- # tool_call_id → (name, args) 从前面的 assistant(tool_calls) 行反查。
1034
- # 只解析**本批选中的**那几个 id:以前是每次触发都把全历史所有 assistant 行的所有
1035
- # tool_calls 的 arguments 反序列化一遍建全表,只为查其中 batch 条(默认 10)。
1036
- # 保险丝一旦启动几乎每轮都触发,于是每轮做一次 O(历史) 的 json.loads。
1037
- wanted = {str(self.history[i].get("tool_call_id") or "") for i in picked}
1038
- wanted.discard("")
1039
- call_meta: dict[str, tuple[str, Any]] = {}
1040
- for m in self.history if wanted else ():
1041
- if m.get("role") != "assistant":
923
+ calls = []
924
+ results = {}
925
+ for i in range(start, len(self.history)):
926
+ row = self.history[i]
927
+ if row.get("role") == "assistant":
928
+ for j, tc in enumerate(row.get("tool_calls") or []):
929
+ if tc.get("id"):
930
+ calls.append((str(tc["id"]), i, j))
931
+ elif row.get("role") == "tool":
932
+ results[str(row.get("tool_call_id") or "")] = i
933
+ protected = {cid for cid, _, _ in calls[-hot_tail:]} if hot_tail > 0 else set()
934
+ keep = set(self.config.insend_distill_keep_tools or ())
935
+ freed_chars = 0
936
+ for cid, i, j in calls:
937
+ if cid in protected or cid not in results:
938
+ continue
939
+ ri = results[cid]
940
+ content = self.history[ri].get("content")
941
+ tc = self.history[i]["tool_calls"][j]
942
+ fn = tc.get("function") or {}
943
+ raw = fn.get("arguments")
944
+ name = str(fn.get("name") or "tool")
945
+ if (name in keep or not isinstance(content, str) or not isinstance(raw, str)
946
+ or content.startswith(self._DISTILL_MARK)):
947
+ continue
948
+ try:
949
+ args = json.loads(raw)
950
+ line = distill(name, args, content, getattr(self, "tool_registry", None))
951
+ except Exception: # a broken projector must not stop the run or starve later calls
952
+ logger.warning("in-send distillation failed for %s", name, exc_info=True)
1042
953
  continue
1043
- for tc in m.get("tool_calls") or []:
1044
- cid = str((tc or {}).get("id") or "")
1045
- if cid not in wanted or cid in call_meta:
1046
- continue
1047
- fn = (tc or {}).get("function") or {}
1048
- args = fn.get("arguments")
1049
- if isinstance(args, str):
1050
- try:
1051
- args = json.loads(args)
1052
- except (TypeError, ValueError):
1053
- args = {"raw": args[:200]}
1054
- call_meta[cid] = (str(fn.get("name") or "tool"), args)
1055
- if len(call_meta) == len(wanted):
1056
- break
1057
- freed = 0
1058
- for i in picked:
1059
- m = self.history[i]
1060
- name, args = call_meta.get(str(m.get("tool_call_id") or ""), ("tool", {}))
1061
- content = str(m.get("content") or "")
1062
- if project_tool is not None:
1063
- try:
1064
- d = project_tool(name, args, content, getattr(self, "tool_registry", None))
1065
- parts = [f"{k}={v}" for k, v in (d or {}).items() if k != "name" and v not in (None, "")]
1066
- line = f"{name}(" + ", ".join(str(x)[:200] for x in parts) + ")"
1067
- except Exception: # noqa: BLE001 — 蒸馏钩子出错就退回截断
1068
- line = f"{name}: {content[:200]}…"
1069
- else:
1070
- line = f"{name}: {content[:200]}…"
1071
954
  seq = seqs[i] if i < len(seqs) else None
1072
955
  coord = (f"recall_send(send_index={self.send_index}, seq={seq})"
1073
- if (self.send_index is not None and seq is not None) else "recall_send")
1074
- new_content = (f"{self._DISTILL_MARK}{self.send_index} seq={seq} — 原文已从上下文移出,"
1075
- f"需要时 {coord} 回取] {line}")
1076
- freed += max(0, (len(content) - len(new_content)) // 4)
1077
- m["content"] = new_content
1078
- for i in picked_args:
1079
- freed += self._distill_tool_call_args(i, seqs)
1080
- return freed
956
+ if self.send_index is not None and seq is not None else "recall_send")
957
+ new_content = f"{self._DISTILL_MARK}{self.send_index} seq={seq}; {coord}] {line}"
958
+ new_raw = json.dumps({"_distilled": coord}, ensure_ascii=False)
959
+ saved = len(raw) + len(content) - len(new_raw) - len(new_content)
960
+ if saved <= 0:
961
+ continue
962
+ new_calls = list(self.history[i]["tool_calls"])
963
+ new_calls[j] = {**tc, "function": {**fn, "arguments": new_raw}}
964
+ self.history[i] = {**self.history[i], "tool_calls": new_calls}
965
+ self.history[ri] = {**self.history[ri], "content": new_content}
966
+ freed_chars += saved
967
+ if target_tokens is not None and freed_chars // 4 >= target_tokens:
968
+ break
969
+ return freed_chars // 4
1081
970
 
1082
971
  def _estimate_history_tokens(self) -> int:
1083
972
  """Current history token estimate, maintained incrementally (SCALE-4).
@@ -1131,42 +1020,22 @@ class AgentPipeline:
1131
1020
  if irr is not None and int(irr) > 0 and self._retire_stale_images(round_index, int(irr)):
1132
1021
  self._tok_len = -1
1133
1022
 
1134
- # ── 6.10.0 send 内保险丝:最早的 n 条工具结果 → 投影行(内存替换,pl_messages 不动)──
1135
- # 触发依据 = 上一轮供应商返回的真实 prompt_tokens(就是当前上下文的真实大小;第一轮
1136
- # 还没有就用估算)。每轮最多蒸馏一批(最早的 n 条、跳过已蒸馏的、不动最近 hot_tail 条);
1137
- # 下一轮真实 prompt 仍超阈值就再蒸馏下一批——逐轮递进,只蒸馏到够用为止。
1023
+ # Fuse uses total prompt size, including the fixed prompt and history prefix.
1138
1024
  dt = self.config.insend_distill_tokens
1139
1025
  if dt is not None and int(dt) > 0 and round_index > 0:
1140
- # 6.27.0:量的是**本 send 内的增长**,不是整个上下文。系统提示词 + 技能 + 工具目录 + 历史
1141
- # 一开局就可能有几万 token,按整体判的话第一轮就超阈值、之后每轮都烧(真实事故:一个
1142
- # 会话 44 次触发,模型自己的发言参数被瘦成占位符,再被照样发进聊天)。保险丝的本意是
1143
- # 「这一个 send 跑得太长」才出手:第一轮的真实 prompt_tokens 是基线,之后每轮减它。
1144
1026
  real = int((self.ctx.token_usage or {}).get("prompt_tokens", 0) or 0)
1145
- if real > 0:
1146
- if self._send_baseline_prompt is None:
1147
- self._send_baseline_prompt = real
1148
- growth = real - int(self._send_baseline_prompt)
1149
- else:
1150
- start = min(int(getattr(self, "_send_start_len", 0) or 0), len(self.history))
1151
- growth = estimate_tokens(self.history[start:])
1152
- if growth >= int(dt):
1027
+ if real and self._send_baseline_prompt is None:
1028
+ self._send_baseline_prompt = real # diagnostics only; never subtracted from trigger
1029
+ total = real or self._estimate_history_tokens()
1030
+ if total >= int(dt):
1153
1031
  freed = self._distill_oldest_tool_rows(
1154
- int(self.config.insend_distill_batch or 10),
1155
- int(self.config.insend_distill_hot_tail or 0),
1032
+ 0, int(self.config.insend_distill_hot_tail or 0),
1033
+ target_tokens=max(1, total - int(dt) + 1),
1156
1034
  )
1157
1035
  if freed:
1158
- self._tok_len = -1 # content shrank in place; force a fresh estimate
1159
- start_i = min(int(getattr(self, "_send_start_len", 0) or 0), len(self.history))
1160
- # freed==0 也要记:那是「保险丝烧完了、再触发也没用」的静默状态,
1161
- # 而它的表现和「一切正常」一模一样——上下文继续涨,日志里一个字都没有。
1162
- logger.info(
1163
- "insend distill: send grew %d >= %d tokens; freed ~%d this round "
1164
- "(send %s, round %d, prompt %d, rows %d) | 大头: %s%s",
1165
- growth, int(dt), freed, self.send_index, round_index, real,
1166
- len(self.history) - start_i, self._context_profile(start_i),
1167
- "" if freed else " ← 没有可回收的了:剩下的要么在热尾、要么已蒸馏、要么是"
1168
- "模型自己的正文(保险丝只动工具结果与调用参数)",
1169
- )
1036
+ self._tok_len = -1
1037
+ logger.info("insend distill: total prompt %d >= %d; freed ~%d tokens",
1038
+ total, int(dt), freed)
1170
1039
  if self.config.microcompact_enabled:
1171
1040
  self.ctx.microcompact(
1172
1041
  self.history,
@@ -1665,6 +1534,7 @@ class AgentPipeline:
1665
1534
  ckpt = self.config.context_checkpoint_tokens
1666
1535
  if (
1667
1536
  ckpt is not None
1537
+ and (self.config.insend_distill_tokens or 0) <= 0
1668
1538
  and int(ckpt) > 0
1669
1539
  and round_idx > 0
1670
1540
  and not self._terminal_grace_active
@@ -420,6 +420,13 @@ class ProjectedRepresentation:
420
420
  source_seq_hi=max(seqs) if seqs else None,
421
421
  )
422
422
 
423
+ def distill_tool_call(
424
+ self, name: str, args: Any, result: Any, tool_registry: ToolRegistry | None
425
+ ) -> str:
426
+ """Public single-call projection seam shared with the in-send context fuse."""
427
+ entry = self._project_tool(name, args, result, tool_registry)
428
+ return json.dumps(entry, ensure_ascii=False, default=str)
429
+
423
430
  def _project_tool(
424
431
  self, name: str | None, args: Any, result: Any, tool_registry: ToolRegistry | None
425
432
  ) -> dict[str, Any]:
@@ -357,7 +357,9 @@ DEFAULT_TOOL_DEFINITIONS: list[ToolDefinition] = [
357
357
  "task_id immediately. action=tool runs one async-capable TOOL in the background "
358
358
  "(tool=<name>, args={…}) and returns a task_id at once — use it for long, "
359
359
  "side-effect-free calls (image generation, web fetches) whose result you do not "
360
- "need immediately. **Results are delivered to you automatically on completion "
360
+ "need immediately. Up to 8 submitted tools execute per session; excess calls are accepted "
361
+ "as queued and start automatically in submission order when a slot opens. No check is needed "
362
+ "to release a slot. **Results are delivered to you automatically on completion "
361
363
  "(in the background updates block or a wake message) — do not poll for them.** "
362
364
  "While a task runs, do other work, or pass_turn if there is nothing else to do. "
363
365
  "action=check is a fallback: call it only when a wake message explicitly asks you to, "
@@ -1262,6 +1262,7 @@ class BackgroundManager:
1262
1262
  # agent when they settle — the user said stop).
1263
1263
  self._stop_tokens: dict[str, Any] = {}
1264
1264
  self._stopped: set[str] = set()
1265
+ self._tool_slots: dict[tuple[Any, int, str | None], tuple[asyncio.Semaphore, int]] = {}
1265
1266
  # Live daemon threads (so shutdown can drain them) and terminal write-backs that
1266
1267
  # could not be delivered to their owning loop (so a later check / shutdown can
1267
1268
  # still persist them instead of leaving the row stuck at 'running' forever).
@@ -1274,7 +1275,7 @@ class BackgroundManager:
1274
1275
  Running tasks are never evicted. Caller must hold ``self._lock``."""
1275
1276
  while len(self.tasks) > _MAX_BG_TASKS:
1276
1277
  for tid, t in self.tasks.items(): # insertion order = oldest first
1277
- if t.get("status") != "running":
1278
+ if t.get("status") not in ("queued", "running"):
1278
1279
  del self.tasks[tid]
1279
1280
  self._threads.pop(tid, None)
1280
1281
  break
@@ -1527,9 +1528,9 @@ class BackgroundManager:
1527
1528
  计费/活动打标上下文天然跟着走,不需要捕获重播。结果持久化进同一张
1528
1529
  background 任务表(command 字段以 ``tool:`` 前缀区分);完成后调用宿主注册的
1529
1530
  ``on_tool_task_complete``(best-effort),由宿主决定要不要唤醒睡着的 agent。
1530
- 进程重启 = 任务丢失,行卡 running,由 check 的 flush_orphaned 语义外**另行**
1531
- 诚实兜底:这里在启动时登记 in-memory,check 未命中 in-memory 且行仍 running
1532
- 时按孤儿报告(复用 shell 分支已有的自愈路径)。
1531
+ 每个 store/sid 的新调用最多 8 个同时执行,超出后按接收顺序等待。
1532
+ 队列驻留当前进程;宿主重启时应把旧 queued/running 行标为 interrupted,
1533
+ 不自动重放可能已经产生外部副作用的调用。adopt 接管已经执行的调用,不重新排队。
1533
1534
  """
1534
1535
  from power_loop.core.agent_context import get_current_loop
1535
1536
 
@@ -1579,21 +1580,21 @@ class BackgroundManager:
1579
1580
  + ("\n可以后台跑的是:" + "、".join(_others) if _others else "")
1580
1581
  )
1581
1582
  store, sid = _current_store_and_session()
1582
- if store is not None and sid is not None:
1583
- rows = await store.list_background_tasks(sid)
1584
- running = sum(1 for r in rows if r.status == "running" and r.command.startswith("tool:"))
1585
- if running >= 8:
1586
- raise ValueError("后台工具任务已达并发上限(8)——先 check 收结果再发新的。")
1587
1583
  task_id = str(uuid.uuid4())[:8]
1588
1584
  args = dict(args or {})
1589
1585
  label = f"tool:{name} " + json.dumps(args, ensure_ascii=False)[:160]
1590
- with self._lock:
1591
- self.tasks[task_id] = {"command": label, "status": "running", "result": None}
1592
1586
  if store is not None and sid is not None:
1593
1587
  await store.upsert_background_task(
1594
- sid, task_id=task_id, command=label, status="running",
1595
- return_code=None, output_tail="(running)",
1588
+ sid, task_id=task_id, command=label, status="queued",
1589
+ return_code=None, output_tail="(queued)",
1596
1590
  )
1591
+ with self._lock:
1592
+ self.tasks[task_id] = {"command": label, "status": "queued", "result": None}
1593
+ # One FIFO admission gate per store/session/event loop. No await between lookup
1594
+ # and registration: concurrent submissions cannot oversubscribe the limit.
1595
+ key = (asyncio.get_running_loop(), id(store), sid)
1596
+ gate, users = self._tool_slots.get(key, (asyncio.Semaphore(8), 0))
1597
+ self._tool_slots[key] = (gate, users + 1)
1597
1598
 
1598
1599
  # Its own ROOT stop token: a background task outlives the send that started it (the
1599
1600
  # UI stop button stops the foreground, not this); only a stop by id reaches it.
@@ -1609,13 +1610,34 @@ class BackgroundManager:
1609
1610
  async def _invoke() -> Any:
1610
1611
  ctok = set_current_cancel_token(tok)
1611
1612
  try:
1612
- return await registry.invoke_async(name, args)
1613
+ async with gate:
1614
+ if store is not None and sid is not None:
1615
+ await store.upsert_background_task(
1616
+ sid, task_id=task_id, command=label, status="running",
1617
+ return_code=None, output_tail="(running)",
1618
+ )
1619
+ with self._lock:
1620
+ self.tasks[task_id]["status"] = "running"
1621
+ return await registry.invoke_async(name, args)
1613
1622
  finally:
1614
1623
  reset_current_cancel_token(ctok)
1615
1624
 
1616
- self._supervise(task_id, label, store, sid, _invoke())
1625
+ worker = self._supervise(task_id, label, store, sid, _invoke())
1626
+
1627
+ def release_pool(_task: Any) -> None:
1628
+ semaphore, count = self._tool_slots[key]
1629
+ if count == 1:
1630
+ del self._tool_slots[key]
1631
+ else:
1632
+ self._tool_slots[key] = (semaphore, count - 1)
1633
+
1634
+ worker.add_done_callback(release_pool)
1635
+ # Start supervision before exposing the id, including for immediate cancellation.
1636
+ await asyncio.sleep(0)
1617
1637
  return (
1618
- f"后台任务已启动:task_id={task_id}({label[:80]})。"
1638
+ f"后台任务已接收:task_id={task_id}({label[:80]})。"
1639
+ f"当前状态:{self.tasks[task_id]['status']}。"
1640
+ "同一会话最多同时执行 8 个后台工具,超出的自动排队,空出名额后自动执行,无需 check 释放名额。"
1619
1641
  "**完成时结果会自动送到你面前,不用去取。** 现在去做别的;"
1620
1642
  "手上没有别的可做就 pass_turn 停轮等它——用 check 反复问「好了没」只会空烧回合,"
1621
1643
  "任务不会因此快一点,用户还得等你。"
@@ -1752,6 +1774,10 @@ class BackgroundManager:
1752
1774
  if worker.done():
1753
1775
  return "finished"
1754
1776
  self._stopped.add(task_id)
1777
+ if self.tasks.get(task_id, {}).get("status") == "queued":
1778
+ worker.cancel()
1779
+ await asyncio.wait({worker}, timeout=5)
1780
+ return "stopped"
1755
1781
  tok = self._stop_tokens.get(task_id)
1756
1782
  if tok is not None and hasattr(tok, "cancel"):
1757
1783
  tok.cancel("stopped")
@@ -1784,10 +1810,10 @@ class BackgroundManager:
1784
1810
  ids = [str(x) for x in (task_ids or []) if str(x).strip()]
1785
1811
  if not ids:
1786
1812
  if store is not None and sid is not None:
1787
- ids = [r.task_id for r in await store.list_background_tasks(sid) if str(r.status) == "running"]
1813
+ ids = [r.task_id for r in await store.list_background_tasks(sid) if str(r.status) in ("queued", "running")]
1788
1814
  else:
1789
1815
  with self._lock:
1790
- ids = [tid for tid, tk in self.tasks.items() if tk.get("status") == "running"]
1816
+ ids = [tid for tid, tk in self.tasks.items() if tk.get("status") in ("queued", "running")]
1791
1817
  if not ids:
1792
1818
  return "没有在跑的后台任务。"
1793
1819
  timeout_s = max(1.0, min(float(timeout_s or 600.0), 3600.0))
@@ -1797,7 +1823,7 @@ class BackgroundManager:
1797
1823
  end = loop.time() + timeout_s
1798
1824
  while loop.time() < end:
1799
1825
  states = [(await self._status_of(store, sid, tid))[0] for tid in ids]
1800
- if all(s not in ("running", "unknown") for s in states):
1826
+ if all(s not in ("queued", "running", "unknown") for s in states):
1801
1827
  return
1802
1828
  futs = {self._workers[tid] for tid in ids if tid in self._workers and not self._workers[tid].done()}
1803
1829
  await asyncio.wait(futs or {asyncio.ensure_future(asyncio.sleep(1.0))}, timeout=1.0,
@@ -1810,7 +1836,7 @@ class BackgroundManager:
1810
1836
  pending = 0
1811
1837
  for tid in ids:
1812
1838
  status, tail = await self._status_of(store, sid, tid)
1813
- pending += status == "running"
1839
+ pending += status in ("queued", "running")
1814
1840
  lines.append(f"{tid}: [{status}] {tail[-1500:] if tail else ''}".rstrip())
1815
1841
  head = ("等的时候来了新消息,先停下来等(任务照常在跑)。" if steered
1816
1842
  else f"还有 {pending} 件没做完(等了 {int(timeout_s)} 秒,任务照常在跑)。" if pending