主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。
- 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件)
- 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入
任何会话、也推进不了目标会话的 read_at
- fail-closed 授权,顺序 = 环境 → 白名单 → 存在性:
非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:<uuid>(ErrSessionNotAllowed)
→ 会话不存在是硬错误(ErrSessionNotFound,绝不返回空材料,防"读不到"被当成"对方没说")
- 排版:头部三行恒定(外部会话材料 · 只读 · 不属于你的记忆 / 来源角色 / 来源会话)
+ 时间跨度与翻页游标 + 逐条「[时间] 说话人:」归属
- 说话人取消息自身 persona_id;'' → 全局默认角色;未知 id 原样显示;user → SenderName(空=主人)
- Execute 返回 string:loop 的 formatToolResult 对 string 原样透传,换行不被 json 转义
- 接线 cmd/cyrene/wire.go 一行 + wireSessionReadTool():能力默认标记(双 false)= owner-only,
不新增任何 bypass 路径;审计仍走 loop 的 gate
- 日志 source=runtime/sessionread,只记 actor/target/count/limit/before/has_older,不含正文
验证:go build/vet ./... 绿;internal/runtime/sessionread(25 测试)+ cmd/cyrene 绿;
linux/amd64 + darwin/arm64 交叉编译绿;
TestWireSessionReadToolRegistersOwnerOnly 断言 owner 放行 / group_member / stranger 拒绝
遗留(登记,未解决):§8.1 约束 4 要求"每次调用落审计",但 loop 的 gate 只在拒绝时发
AuditEvent ⇒ 成功读取目前只有日志可追溯,未进审计库;补 loop 侧成功路径钩子需先定口径。
181 lines
6.1 KiB
Go
181 lines
6.1 KiB
Go
package sessionread
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// ToolName is the registered tool name the model calls to read another session's
|
|
// context (design/22 §8.1 工具形态).
|
|
const ToolName = "session_read"
|
|
|
|
// Compile-time assertion that Tool implements sdk.Tool.
|
|
var _ sdk.Tool = (*Tool)(nil)
|
|
|
|
// Tool is the agent-facing shell of the cross-session read: it parses the tool
|
|
// arguments, delegates to Reader for the fail-closed authorization and the read,
|
|
// and hands the model the formatted material.
|
|
//
|
|
// It is safe for concurrent use and holds no per-call state.
|
|
type Tool struct {
|
|
reader *Reader
|
|
}
|
|
|
|
// Option customizes the Tool (and the Reader behind it) built by New.
|
|
type Option func(*Reader)
|
|
|
|
// WithHistory injects the read-only history view the material is read from.
|
|
// *history.Store satisfies it. Without it every call fails with ErrNoHistory.
|
|
func WithHistory(h HistoryReader) Option {
|
|
return func(r *Reader) { r.history = h }
|
|
}
|
|
|
|
// WithPersonas injects the role-name lookup used for the attribution labels.
|
|
// Without it the material shows raw role ids (no name is invented).
|
|
func WithPersonas(p PersonaNamer) Option {
|
|
return func(r *Reader) { r.personas = p }
|
|
}
|
|
|
|
// WithLocation sets the timezone the timestamps are rendered in. A nil location
|
|
// keeps time.Local, matching the process-wide convention. The composition root
|
|
// passes the configured TZ so the material reads in the same clock as the loop's
|
|
// per-round "current time" injection.
|
|
func WithLocation(loc *time.Location) Option {
|
|
return func(r *Reader) {
|
|
if loc != nil {
|
|
r.loc = loc
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithLogger replaces the reader's logger (tagged source "runtime/sessionread").
|
|
func WithLogger(l *slog.Logger) Option {
|
|
return func(r *Reader) { r.log = sourceLog(l) }
|
|
}
|
|
|
|
// New builds the tool over the injected dependencies. A tool built without a
|
|
// history reader still constructs (the composition root can log and refuse to
|
|
// register it); every call then fails with ErrNoHistory.
|
|
func New(opts ...Option) *Tool {
|
|
r := &Reader{loc: time.Local, log: sourceLog(nil)}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(r)
|
|
}
|
|
}
|
|
if r.personas == nil {
|
|
r.personas = noPersonaNames{}
|
|
}
|
|
return &Tool{reader: r}
|
|
}
|
|
|
|
// Name returns the tool identifier.
|
|
func (t *Tool) Name() string { return ToolName }
|
|
|
|
// Description explains the tool to the model: it reads someone else's session as
|
|
// *external, read-only material* that is explicitly not the caller's own memory or
|
|
// voice (design/22 §8-3 的原意), reachable only for the Web sessions.
|
|
func (t *Tool) Description() string {
|
|
return "Read another session's recent messages as external, read-only material. " +
|
|
"The result is a transcript prefixed with a fixed attribution header naming the source role " +
|
|
"and source session, so it is explicitly NOT your own memory and not your own words — never " +
|
|
"quote or continue it as if you had said it. Only the Web main session (\"web\") and Web sub " +
|
|
"sessions (\"web:<uuid>\") can be read, only from the owner's conversation; the target " +
|
|
"session's read cursor is not advanced and nothing is written."
|
|
}
|
|
|
|
// Parameters returns the input JSON Schema (design/22 §8.1 参数表). No "maximum"
|
|
// bound is declared on limit on purpose: a limit above the cap is clamped in
|
|
// Execute (mirroring GET /sessions/{id}/messages) instead of being rejected as an
|
|
// invalid argument.
|
|
func (t *Tool) Parameters() (map[string]any, error) {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"sessionId": map[string]any{
|
|
"type": "string",
|
|
"description": "Target session id to read: the Web main session \"web\" or a Web sub " +
|
|
"session \"web:<uuid>\". Any other session id is rejected.",
|
|
},
|
|
"limit": map[string]any{
|
|
"type": "integer",
|
|
"description": "Max messages to return (optional, default 50, capped at 200).",
|
|
},
|
|
"before": map[string]any{
|
|
"type": "integer",
|
|
"description": "Millisecond cursor: return only messages older than it, to page further " +
|
|
"back (optional; omit for the newest page).",
|
|
},
|
|
},
|
|
"required": []any{"sessionId"},
|
|
}, nil
|
|
}
|
|
|
|
// Execute reads the requested session and returns the formatted material.
|
|
//
|
|
// The return value is a plain string, not a struct: the loop renders a string tool
|
|
// result verbatim (loop.formatToolResult passes strings through, while any other
|
|
// value is JSON-encoded and would escape every newline), and the material is
|
|
// line-oriented text whose attribution header must survive intact.
|
|
func (t *Tool) Execute(ctx context.Context, args any) (any, error) {
|
|
m, ok := args.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: args must be an object", ToolName)
|
|
}
|
|
sessionID := strings.TrimSpace(stringArg(m, "sessionId"))
|
|
if sessionID == "" {
|
|
return nil, fmt.Errorf("%s: sessionId is required", ToolName)
|
|
}
|
|
text, err := t.reader.Read(ctx, Request{
|
|
SessionID: sessionID,
|
|
Limit: intArg(m, "limit"),
|
|
Before: int64(intArg(m, "before")),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", ToolName, err)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
// stringArg reads a string argument, tolerating a missing or non-string value.
|
|
func stringArg(m map[string]any, key string) string {
|
|
s, _ := m[key].(string)
|
|
return s
|
|
}
|
|
|
|
// intArg reads an integer argument, tolerating a missing, non-numeric or
|
|
// fractional value (0 then means "not supplied", which every caller treats as its
|
|
// own default). A decoded-JSON number arrives as float64, hence the type switch.
|
|
func intArg(m map[string]any, key string) int {
|
|
switch v := m[key].(type) {
|
|
case int:
|
|
return v
|
|
case int32:
|
|
return int(v)
|
|
case int64:
|
|
return int(v)
|
|
case float32:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// sourceLog returns l tagged with source "runtime/sessionread", replacing a nil
|
|
// logger with a discard one so the reader can always log.
|
|
func sourceLog(l *slog.Logger) *slog.Logger {
|
|
if l == nil {
|
|
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
return logger.WithSource(l, "runtime/sessionread")
|
|
}
|