8000 feat(agentcore): add chatlog projections and materialization by Fodesu · Pull Request #86 · felinics/twilight · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
572 changes: 572 additions & 0 deletions agentcore/chatlog/chatlog.go

Large diffs are not rendered by default.

516 changes: 516 additions & 0 deletions agentcore/chatlog/chatlog_test.go

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions agentcore/chatlog/foldbench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package chatlog

import (
"fmt"
"github.com/felinics/twilight/agentcore/ledger"
"github.com/felinics/twilight/agentcore/module"
"github.com/felinics/twilight/agentcore/run"
"github.com/felinics/twilight/agentcore/run/model"
"github.com/felinics/twilight/agentcore/run/sessionstore"
"testing"
)

// benchEvents builds n model_step_completed facts, the cheapest event that
// grows both the maps and EntryOrder of each projection. Each event carries
// the ledger Position a fold would stamp on it.
func benchEvents(n int) []module.DecodedEvent {
out := make([]module.DecodedEvent, n)
for i := range out {
out[i] = module.DecodedEvent{
Position: ledger.Position{Commit: ledger.CommitSeq(i)},
Value: sessionstore.Event{RunID: "r", Fact: run.ModelStepCompleted{StepID: run.StepID(fmt.Sprint(i)), FinishReason: model.FinishReasonStop, ResultDigest: "sha256:x"}},
}
}
return out
}

func benchFold(b *testing.B, def module.ProjectionDefinition, n int) {
events := benchEvents(n)
b.ResetTimer()
for i := 0; i < b.N; i++ {
state, err := def.Initial()
if err != nil {
b.Fatal(err)
}
for _, e := range events {
if state, err = def.Apply(state, e); err != nil {
b.Fatal(err)
}
}
}
}

// Both folds once copied the whole derived state per event, so folding an
// N-event log cost O(N^2) (3200 events: 194 ms Surface, 66 ms Context).
// Context now appends and copies only the map an event writes; Surface keeps
// its content in persistent Tables (O(sqrt(n)) per write). These benchmarks
// pin the shape: doubling N should roughly double elapsed time. The cost is
// paid by OpenWriter's rebuild and ProjectionReader.Load, which fold the log.
func BenchmarkSurfaceFold(b *testing.B) {
for _, n := range []int{400, 800, 1600, 3200} {
b.Run(fmt.Sprint(n), func(b *testing.B) { benchFold(b, SurfaceProjection, n) })
}
}

func BenchmarkContextFold(b *testing.B) {
for _, n := range []int{400, 800, 1600, 3200} {
b.Run(fmt.Sprint(n), func(b *testing.B) { benchFold(b, ContextProjection, n) })
}
}
75 changes: 75 additions & 0 deletions agentcore/chatlog/golden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package chatlog_test

import (
"github.com/felinics/twilight/agentcore/chatlog"
"github.com/felinics/twilight/agentcore/jsonstable"
"github.com/felinics/twilight/agentcore/ledger"
"github.com/felinics/twilight/agentcore/module"
"github.com/felinics/twilight/agentcore/run/sessionstore"
"testing"
)

func freezeChatlog(t *testing.T, name, got, want string) {
t.Helper()
if got == want {
return
}
if want == "" {
t.Errorf("UNSET %s = %s", name, got)
return
}
t.Errorf("golden %s drifted — an intentional wire change must update this fixture and agent-session-chatlog.md:\n got: %s\nwant: %s", name, got, want)
}

// TestChatlogWireGolden freezes the compaction digest preimages and one
// registry-encoded payload of payload version 1.
func TestChatlogWireGolden(t *testing.T) {
pairs := []chatlog.EntryDigestPair{
{Kind: chatlog.EntryInput, ID: "in-1", Digest: "sha256:aa"},
{Kind: chatlog.EntryAssistant, ID: "as-1", Digest: "sha256:bb"},
}
base, err := chatlog.DigestBaseContext(pairs)
if err != nil {
t.Fatal(err)
}
freezeChatlog(t, "base context digest", string(base), "sha256:5b01f17f53ad3e349e1b4d89e673a1647bb1721c3f3c0768a951e2d4a57cac5b")

emptyA, err := chatlog.DigestBaseContext(nil)
if err != nil {
t.Fatal(err)
}
emptyB, err := chatlog.DigestBaseContext([]chatlog.EntryDigestPair{})
if err != nil {
t.Fatal(err)
}
if emptyA != emptyB {
t.Fatal("nil and empty base must digest identically")
}
freezeChatlog(t, "empty base context digest", string(emptyA), "sha256:5b94ad57d55dbade56535b5c515bee09d39d60bc16440746d97d417c934ec004")

cp := chatlog.CompactionCreatedPayload{
CompactionID: "ckpt-1",
CoveredThrough: ledger.Position{Commit: 7},
BaseContextDigest: base,
SummaryID: "sum-1",
SummaryDigest: "sha256:cc",
Retained: pairs[1:],
}
cpd, err := chatlog.DigestCompaction(&cp)
if err != nil {
t.Fatal(err)
}
freezeChatlog(t, "compaction digest", string(cpd), "sha256:bbfcad9e58bc7a287bd5519b5ecde07dc2926ba9f88363bf15bc3b6ec10b630f")

reg, err := module.BuildRegistry(sessionstore.Module, chatlog.Module)
if err != nil {
t.Fatal(err)
}
wire, err := reg.Encode(chatlog.TypeInputSubmitted, chatlog.InputSubmittedPayload{
InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1,
})
if err != nil {
t.Fatal(err)
}
freezeChatlog(t, "input_submitted wire", wire.String(), `{"content":{"text":"hi"},"inputId":"in-1","submittedAtUnixMilli":1,"v":1}`)
}
182 changes: 182 additions & 0 deletions agentcore/chatlog/materialize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package chatlog

import (
"context"
"fmt"

"github.com/felinics/twilight/agentcore/jsonstable"
"github.com/felinics/twilight/agentcore/run"
"github.com/felinics/twilight/agentcore/run/model"
"github.com/felinics/twilight/agentcore/run/schema"
)

// ContentResolver resolves the frozen bodies structural entries name by
// digest (RUN-WIR-4). run/sessionstore.Content is the first-party
// implementation; the projections never call it (CHT-MAT-1).
type ContentResolver interface {
ModelResult(context.Context, jsonstable.Digest) (model.ModelResult, error)
ToolOutput(context.Context, jsonstable.Digest) (run.CanonicalJSON, error)
ToolResponse(context.Context, jsonstable.Digest) (run.CanonicalJSON, error)
}

// Call is one tool call of a materialized assistant: the Run's CallID paired
// with the provider's identifiers and arguments from the frozen result.
type Call struct {
CallID CallID
ProviderCallID string
Name string
Input run.CanonicalJSON
}

// Materialized is one entry with its frozen body resolved: Result and Calls
// for an assistant, Output for a successful tool result. Input, failed tool
// results and summaries carry their content in the entry itself.
type Materialized struct {
Entry *Entry
Result *model.ModelResult
Calls []Call
Output *run.CanonicalJSON
}

// Text is the rendering of the entry's main content: the assistant text, the
// tool output or failure, the summary text. Inputs are rendered by the layer
// that knows their payload shape (DEC-INP-1).
func (m *Materialized) Text() string {
switch m.Entry.Kind {
case EntryAssistant:
if m.Result != nil {
return m.Result.Text
}
case EntryToolResult:
if m.Output != nil {
return m.Output.String()
}
return m.Entry.ToolResult.FailureText()
case EntrySummary:
return PartsText(m.Entry.Summary.Parts)
}
return ""
}

// Materializer resolves entries through a ContentResolver, reading each
// digest at most once per Materializer (CHT-MAT-1).
type Materializer struct {
content ContentResolver
results map[jsonstable.Digest]*model.ModelResult
outputs map[jsonstable.Digest]*run.CanonicalJSON
}

func NewMaterializer(content ContentResolver) *Materializer {
return &Materializer{content: content, results: map[jsonstable.Digest]*model.ModelResult{}, outputs: map[jsonstable.Digest]*run.CanonicalJSON{}}
}

// Entries materializes every entry in order.
func (m *Materializer) Entries(ctx context.Context, entries []Entry) ([]Materialized, error) {
out := make([]Materialized, 0, len(entries))
for i := range entries {
e, err := m.Entry(ctx, &entries[i])
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}

// Entry materializes one entry.
func (m *Materializer) Entry(ctx context.Context, e *Entry) (Materialized, error) {
out := Materialized{Entry: e}
switch e.Kind {
case EntryAssistant:
if e.Assistant == nil {
return out, fmt.Errorf("chatlog: assistant entry %s has no assistant", e.ID)
}
result, err := m.result(ctx, e.Assistant.ResultDigest)
if err != nil {
return out, err
}
calls, err := pairCalls(e.Assistant, result)
if err != nil {
return out, err
}
out.Result, out.Calls = result, calls
case EntryToolResult:
r := e.ToolResult
if r == nil {
return out, fmt.Errorf("chatlog: tool result entry %s has no result", e.ID)
}
if r.Status != ToolSuccess {
return out, nil
}
output, err := m.output(ctx, r)
if err != nil {
return out, err
}
out.Output = output
}
return out, nil
}

func (m *Materializer) result(ctx context.Context, digest jsonstable.Digest) (*model.ModelResult, error) {
if r, ok := m.results[digest]; ok {
return r, nil
}
if m.content == nil {
return nil, fmt.Errorf("chatlog: no content resolver for model result %s", digest)
}
r, err := m.content.ModelResult(ctx, digest)
if err != nil {
return nil, err
}
m.results[digest] = &r
return &r, nil
}

func (m *Materializer) output(ctx context.Context, r *ToolResult) (*run.CanonicalJSON, error) {
if o, ok := m.outputs[r.OutputDigest]; ok {
return o, nil
}
if m.content == nil {
return nil, fmt.Errorf("chatlog: no content resolver for tool result %s", r.ID)
}
var (
o run.CanonicalJSON
err error
)
switch r.Source {
case SourceToolOutput:
o, err = m.content.ToolOutput(ctx, r.OutputDigest)
case SourceToolResponse:
o, err = m.content.ToolResponse(ctx, r.OutputDigest)
default:
return nil, fmt.Errorf("chatlog: tool result %s names no body", r.ID)
}
if err != nil {
return nil, err
}
m.outputs[r.OutputDigest] = &o
return &o, nil
}

// pairCalls zips the Run-assigned CallIDs with the result's ToolCalls. A
// result issued without a ToolStepOpened in the entry (no CallIDs) derives
// them the way the protocol does (RUN-WIR-4 identity table).
func pairCalls(a *Assistant, result *model.ModelResult) ([]Call, error) {
if len(result.ToolCalls) == 0 {
return nil, nil
}
if len(a.CallIDs) != 0 && len(a.CallIDs) != len(result.ToolCalls) {
return nil, fmt.Errorf("chatlog: assistant %s has %d call ids for %d tool calls", a.ID, len(a.CallIDs), len(result.ToolCalls))
}
calls := make([]Call, len(result.ToolCalls))
for i, tc := range result.ToolCalls {
var id CallID
if len(a.CallIDs) != 0 {
id = a.CallIDs[i]
} else {
id = CallID(schema.Identity().DeriveCallID(a.StepID, i))
}
calls[i] = Call{CallID: id, ProviderCallID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input.Canonical()}
}
return calls, nil
}
Loading
Loading
0