Files
AskaEth 08b3a01752
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
主人 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 侧成功路径钩子需先定口径。
2026-10-03 21:33:40 +08:00

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")
}