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:\") 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:\". 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") }