chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# plugins package marker
|
||||
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
|
||||
# 模块级注册表: event_name -> [callback, ...]
|
||||
_registry = {}
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def register(event):
|
||||
def decorator(fn):
|
||||
_registry.setdefault(event, []).append(fn)
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
def trigger(event, ctx: dict):
|
||||
for fn in _registry.get(event, []):
|
||||
try:
|
||||
r = fn(ctx)
|
||||
if isinstance(r, dict):
|
||||
ctx = r
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[hooks] {event} callback error: {e}\n")
|
||||
return ctx
|
||||
|
||||
|
||||
def unregister(event, fn):
|
||||
try:
|
||||
_registry[event] = [f for f in _registry[event] if f is not fn]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def clear(event=None):
|
||||
if event:
|
||||
_registry.pop(event, None)
|
||||
else:
|
||||
_registry.clear()
|
||||
|
||||
|
||||
def has(event):
|
||||
return bool(_registry.get(event))
|
||||
|
||||
|
||||
def discover_and_load(plugin_dir=None):
|
||||
if plugin_dir is None:
|
||||
plugin_dir = os.path.join(_PROJECT_ROOT, 'plugins')
|
||||
if not os.path.isdir(plugin_dir):
|
||||
return
|
||||
parent = os.path.dirname(plugin_dir)
|
||||
if parent not in sys.path:
|
||||
sys.path.insert(0, parent)
|
||||
for fn in sorted(os.listdir(plugin_dir)):
|
||||
if fn.startswith('_') or not fn.endswith('.py'):
|
||||
continue
|
||||
name = fn[:-3]
|
||||
load(name)
|
||||
|
||||
|
||||
def load(name):
|
||||
try:
|
||||
importlib.import_module(f'plugins.{name}')
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[hooks] plugin '{name}' load failed: {e}\n")
|
||||
return False
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Langfuse tracing via hook system. Self-activates on import if langfuse_config exists in mykey.
|
||||
|
||||
Replaces old monkey-patch approach with hooks on:
|
||||
- agent_before / agent_after -> agent trace
|
||||
- llm_before / llm_after -> generation span
|
||||
- tool_before / tool_after -> tool span
|
||||
|
||||
Usage tracking (SSE parser wrapping) stays as internal llmcore patch.
|
||||
"""
|
||||
import threading, sys
|
||||
|
||||
try:
|
||||
from llmcore import _load_mykeys
|
||||
_cfg = _load_mykeys().get('langfuse_config')
|
||||
from langfuse import Langfuse
|
||||
_lf = Langfuse(**_cfg) if _cfg else None
|
||||
except Exception:
|
||||
_lf = None
|
||||
|
||||
if _lf:
|
||||
import plugins.hooks as hooks, llmcore
|
||||
_tls = threading.local()
|
||||
|
||||
# ── Agent trace ──────────────────────────────────────────────
|
||||
|
||||
@hooks.register('agent_before')
|
||||
def _on_agent_before(ctx):
|
||||
try:
|
||||
_tls.trace_obs = _lf.start_observation(
|
||||
name='agent.task', as_type='agent',
|
||||
input={'user_input': ctx.get('user_input', '')})
|
||||
except Exception:
|
||||
_tls.trace_obs = None
|
||||
|
||||
@hooks.register('agent_after')
|
||||
def _on_agent_after(ctx):
|
||||
try:
|
||||
obs = getattr(_tls, 'trace_obs', None)
|
||||
if obs:
|
||||
obs.update(output=ctx.get('exit_reason'))
|
||||
obs.end()
|
||||
_tls.trace_obs = None
|
||||
_lf.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── LLM generation span (replaces _write_llm_log patch) ─────
|
||||
|
||||
@hooks.register('llm_before')
|
||||
def _on_llm_before(ctx):
|
||||
try:
|
||||
_tls.gen = _lf.start_observation(
|
||||
name='llm.chat', as_type='generation',
|
||||
input=str(ctx.get('messages', ''))[:20000])
|
||||
_tls._usage = None
|
||||
except Exception:
|
||||
_tls.gen = None
|
||||
|
||||
@hooks.register('llm_after')
|
||||
def _on_llm_after(ctx):
|
||||
try:
|
||||
gen = getattr(_tls, 'gen', None)
|
||||
if gen:
|
||||
gen.update(output=str(ctx.get('response', ''))[:20000],
|
||||
usage_details=getattr(_tls, '_usage', None))
|
||||
gen.end()
|
||||
_tls.gen = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Tool spans (replaces tool_before/after_callback patches) ─
|
||||
|
||||
@hooks.register('tool_before')
|
||||
def _on_tool_before(ctx):
|
||||
try:
|
||||
name = ctx.get('tool_name', '?')
|
||||
args = {k: v for k, v in (ctx.get('args') or {}).items() if not k.startswith('_')}
|
||||
if not hasattr(_tls, 'tstack'): _tls.tstack = []
|
||||
_tls.tstack.append(_lf.start_observation(name=name, as_type='tool', input=args))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@hooks.register('tool_after')
|
||||
def _on_tool_after(ctx):
|
||||
try:
|
||||
stack = getattr(_tls, 'tstack', [])
|
||||
if stack:
|
||||
sp = stack.pop()
|
||||
ret = ctx.get('ret')
|
||||
out = {'data': ret.data, 'next_prompt': ret.next_prompt,
|
||||
'should_exit': ret.should_exit} if ret else None
|
||||
sp.update(output=out); sp.end()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Usage tracking: tee SSE data for token counts ───────────
|
||||
|
||||
def _extract_usage(buf):
|
||||
u = {}
|
||||
import json as _j
|
||||
for line in buf:
|
||||
s = line.decode('utf-8', 'replace') if isinstance(line, (bytes, bytearray)) else line
|
||||
if not s or not s.startswith('data:'): continue
|
||||
ds = s[5:].lstrip()
|
||||
if ds == '[DONE]': continue
|
||||
try: evt = _j.loads(ds)
|
||||
except: continue
|
||||
if evt.get('type') == 'message_start':
|
||||
us = evt.get('message', {}).get('usage', {}) or {}
|
||||
u['input'] = us.get('input_tokens', u.get('input', 0))
|
||||
if us.get('cache_creation_input_tokens'): u['cache_creation_input_tokens'] = us['cache_creation_input_tokens']
|
||||
if us.get('cache_read_input_tokens'): u['cache_read_input_tokens'] = us['cache_read_input_tokens']
|
||||
elif evt.get('type') == 'message_delta':
|
||||
ot = (evt.get('usage') or {}).get('output_tokens')
|
||||
if ot: u['output'] = ot
|
||||
elif evt.get('type') == 'response.completed':
|
||||
us = evt.get('response', {}).get('usage', {}) or {}
|
||||
if us.get('input_tokens'): u['input'] = us['input_tokens']
|
||||
if us.get('output_tokens'): u['output'] = us['output_tokens']
|
||||
cr = (us.get('input_tokens_details') or {}).get('cached_tokens')
|
||||
if cr: u['cache_read_input_tokens'] = cr
|
||||
else:
|
||||
us = evt.get('usage')
|
||||
if us:
|
||||
if us.get('prompt_tokens'): u['input'] = us['prompt_tokens']
|
||||
if us.get('completion_tokens'): u['output'] = us['completion_tokens']
|
||||
cr = (us.get('prompt_tokens_details') or {}).get('cached_tokens')
|
||||
if cr: u['cache_read_input_tokens'] = cr
|
||||
return u or None
|
||||
|
||||
def _wrap_parser(orig):
|
||||
def wrapped(resp_lines, *a, **kw):
|
||||
buf = []
|
||||
def tee():
|
||||
for ln in resp_lines:
|
||||
buf.append(ln); yield ln
|
||||
ret = yield from orig(tee(), *a, **kw)
|
||||
try:
|
||||
_tls._usage = _extract_usage(buf)
|
||||
except Exception:
|
||||
pass
|
||||
return ret
|
||||
return wrapped
|
||||
|
||||
llmcore._parse_claude_sse = _wrap_parser(llmcore._parse_claude_sse)
|
||||
llmcore._parse_openai_sse = _wrap_parser(llmcore._parse_openai_sse)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Project Mode plugin — 零核心代码改动实现 GA 项目模式。
|
||||
|
||||
机制:注册 agent_before hook(agent_loop.py 中每个用户轮触发一次)。
|
||||
当项目模式激活时,把 L1 层(规则 + 记忆文件指针 + 收尾纪律)追加到最后一条 user message(str 直接拼接,多模态 list 追加 text block)。
|
||||
两层设计:L1 每轮全量注入(轻量、稳定);L2 = project_memory.md 全文不注入,
|
||||
由模型按 L1 中的指针与线索(行数/大小)自行判断是否用 file 工具读取。
|
||||
利用 messages 是 list 引用的事实,直接 mutate 即反映到真正发给 LLM 的内容。
|
||||
|
||||
激活态保存在当前 GenericAgent 实例的 _ga_project_mode_name;各会话互不干扰。
|
||||
|
||||
目录约定:
|
||||
temp/projects/<项目名>/project_memory.md 单文件全文注入的项目记忆
|
||||
temp/projects/<项目名>/ 项目私域文件(todo 等),解决多项目覆盖
|
||||
"""
|
||||
import os
|
||||
import plugins.hooks as hooks
|
||||
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_TEMP = os.path.join(_PROJECT_ROOT, 'temp')
|
||||
|
||||
|
||||
def _active_project(ctx=None):
|
||||
"""返回当前 Agent 实例激活的项目名;未激活返回 None。"""
|
||||
handler = ctx.get('handler') if isinstance(ctx, dict) else None
|
||||
parent = getattr(handler, 'parent', None)
|
||||
return getattr(parent, '_ga_project_mode_name', None) or None
|
||||
|
||||
|
||||
def _project_dir(name):
|
||||
return os.path.join(_TEMP, 'projects', name)
|
||||
|
||||
|
||||
def _mem_path(name):
|
||||
return os.path.join(_project_dir(name), 'project_memory.md')
|
||||
|
||||
|
||||
def _memory_stat(name):
|
||||
"""返回 project_memory.md 的 (存在, 行数, 字节数),供 L1 指针给模型判断依据。"""
|
||||
path = _mem_path(name)
|
||||
if os.path.isfile(path):
|
||||
data = open(path, encoding='utf-8').read()
|
||||
return True, len(data.splitlines()), len(data.encode('utf-8'))
|
||||
return False, 0, 0
|
||||
|
||||
|
||||
def _build_injection(name):
|
||||
"""构造追加到 user message 末尾的内容(两层设计的 L1 层)。
|
||||
|
||||
L1(每轮全量注入):规范/规则/操作说明 + 记忆文件指针(含行数/大小线索)。
|
||||
L2(按需):project_memory.md 全文不注入,由模型自行判断是否用 file 工具去读。
|
||||
"""
|
||||
pdir = _project_dir(name)
|
||||
mem_path = _mem_path(name)
|
||||
exists, lines, nbytes = _memory_stat(name)
|
||||
if exists and nbytes > 0:
|
||||
mem_hint = (
|
||||
f"项目全量记忆在 {mem_path}({lines} 行 / {nbytes} 字节)。"
|
||||
f"本轮任务若涉及项目上下文(接续工作、查约定、避坑),先读它再动手;"
|
||||
f"若与项目认知无关(闲聊、独立小事),可不读。自行判断。"
|
||||
)
|
||||
else:
|
||||
mem_hint = f"项目记忆 {mem_path} 暂为空(本项目尚无沉淀),无需读取。"
|
||||
return (
|
||||
f"\n\n---\n"
|
||||
f"[PROJECT MODE: {name}]\n"
|
||||
f"你正在「{name}」项目模式中。\n\n"
|
||||
f"## 规则\n"
|
||||
f"- 项目私域目录:{pdir}(todo、草稿、产物一律放这里,勿放 temp 根目录)\n"
|
||||
f"- {mem_hint}\n\n"
|
||||
f"## 收尾纪律\n"
|
||||
f"干完本轮活后自问一个问题:「记忆归零、重新接手本项目的我,缺了本轮哪条信息会重复付出认知代价"
|
||||
f"——再踩一次坑、再摸索一次、再问一次用户?」会的,就用 file 工具把那条追加进 {mem_path},"
|
||||
f"写成未来的自己能直接复用的一句话;不会的,一个字都不写。\n"
|
||||
f"---"
|
||||
)
|
||||
|
||||
|
||||
@hooks.register('agent_before')
|
||||
def inject_project_context(ctx):
|
||||
"""每个用户轮起始时,若项目模式激活,把项目上下文追加到 user message。"""
|
||||
name = _active_project(ctx)
|
||||
if not name:
|
||||
return # 未激活,普通模式,什么都不做
|
||||
|
||||
# 从尾部找最后一条 user message(不依赖 messages[1] 的位置约定)
|
||||
um = next((m for m in reversed(ctx.get('messages') or [])
|
||||
if isinstance(m, dict) and m.get('role') == 'user'), None)
|
||||
if um is None:
|
||||
return
|
||||
content = um.get('content')
|
||||
if isinstance(content, str):
|
||||
um['content'] = content + _build_injection(name)
|
||||
elif isinstance(content, list): # 多模态:追加 text block
|
||||
content.append({'type': 'text', 'text': _build_injection(name)})
|
||||
Reference in New Issue
Block a user