X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/63278e5c97fbad815c37c02f17d562367f4460a7..b2873a2a0bf8b0ba648aebac51759e5fed89a2e2:/probe.go?ds=inline diff --git a/probe.go b/probe.go index a1af30f..9d272ec 100644 --- a/probe.go +++ b/probe.go @@ -33,17 +33,20 @@ const ( probeLineBytes = 1 << 20 ) -// probe runs "onechat probe LOG COUNT". It appends the first COUNT messages of -// LOG, one JSON object {kind, text} per line, to a new chat in probe/ in the -// data directory, and builds its tree with the real compactor. Before each -// user message it waits for the builds, as a turn does. It stops at the first -// failed call, after probeCallLimit calls, or when the used percent of a quota -// window grows by more than probeQuotaLimit points. At the end it reports the -// calls, the time per call, the calls at once, the cached share, the quota, -// the retries, and the line sizes. -func probe(ctx context.Context, args []string, out io.Writer) error { - if len(args) != 2 { - return errors.New("usage: onechat probe LOG COUNT") +// probe runs "onechat probe LOG COUNT [BUILDS]". It appends the first COUNT +// messages of LOG, one JSON object {kind, text} per line, to a new chat in +// probe/ in the data directory, and builds its tree with the real compactor. +// Before each user message it waits for the builds, as a turn does. It stops at +// the first failed call, after probeCallLimit calls, or when the used percent +// of a quota window grows by more than probeQuotaLimit points. At the end it +// reports the calls, the time per call, the calls at once, the cached share, +// the quota, the retries, and the line sizes. BUILDS, from 1 to maxBuilds, +// limits the builds that call the model at once in this run only. The core +// still queues up to maxBuilds builds. The run holds the lock of the token +// file, so it does not run while the daemon runs. +func probe(ctx context.Context, args []string, out io.Writer) (err error) { + if len(args) != 2 && len(args) != 3 { + return errors.New("usage: onechat probe LOG COUNT [BUILDS]") } count, err := strconv.Atoi(args[1]) @@ -51,6 +54,14 @@ func probe(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("COUNT %q is not a positive number", args[1]) } + builds := maxBuilds + if len(args) == 3 { + builds, err = strconv.Atoi(args[2]) + if err != nil || builds < 1 || builds > maxBuilds { + return fmt.Errorf("BUILDS %q is not a number from 1 to %d", args[2], maxBuilds) + } + } + entries, err := readProbeLog(args[0], count) if err != nil { return err @@ -66,6 +77,15 @@ func probe(ctx context.Context, args []string, out io.Writer) error { return err } + tokens, err := lockTokens(dir) + if err != nil { + return err + } + + defer func() { + err = errors.Join(err, tokens.Close()) + }() + address, auth, err := modelEndpoint(config, dir) if err != nil { return err @@ -80,12 +100,17 @@ func probe(ctx context.Context, args []string, out io.Writer) error { usagePath := filepath.Join(root, "usage.jsonl") client := newClient(address, auth, "onechat-"+probeName, usagePath) client.LimitCalls(probeCallLimit) - memory, err := openMemory(ctx, root, time.Now, newCompactor(client, probeName, config.Compactor)) + summarizer := slotSummarizer{ + inner: newCompactor(client, probePrompt, config.Compactor), + slots: make(chan struct{}, builds), + } + + memory, err := openMemory(ctx, root, time.Now, summarizer) if err != nil { return err } - _, err = fmt.Fprintf(out, "probe: %d messages into %s with %s at %s effort\n", len(entries), root, config.Compactor.Model, config.Compactor.Effort) + _, err = fmt.Fprintf(out, "probe: %d messages into %s with %s at %s effort, %d builds at once\n", len(entries), root, config.Compactor.Model, config.Compactor.Effort, builds) if err != nil { return errors.Join(err, memory.Close()) } @@ -198,6 +223,34 @@ func (r *probeRun) check() string { return probeStop(records) } +// slotSummarizer lets at most cap(slots) builds call inner at once. A build +// past the limit waits for a slot. +type slotSummarizer struct { + inner Summarizer + slots chan struct{} +} + +// Summarize waits for a slot and builds the node with inner. +func (s slotSummarizer) Summarize(ctx context.Context, task Task) (string, error) { + select { + case s.slots <- struct{}{}: + case <-ctx.Done(): + return "", ctx.Err() + } + + defer func() { + <-s.slots + }() + + return s.inner.Summarize(ctx, task) +} + +// probePrompt returns the system prompt of a probe run: the prompt of the agent +// probeName, without instructions. +func probePrompt() (string, error) { + return prompt(probeName, ""), nil +} + // probeEntry is one message of a probe log. type probeEntry struct { Kind string `json:"kind"` @@ -319,7 +372,9 @@ func builtByModel(memory *Memory, key nodeKey) (bool, error) { return !free, nil } -// readUsageFile reads the records of usage.jsonl at path. +// readUsageFile reads the records of usage.jsonl at path, in the order of the +// start of their calls. The file has them in the order of the end of their +// calls, which differs when calls run at once. func readUsageFile(path string) (records []usageRecord, err error) { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { @@ -351,9 +406,24 @@ func readUsageFile(path string) (records []usageRecord, err error) { return nil, fmt.Errorf("read usage file: %w", err) } + slices.SortStableFunc(records, func(a, b usageRecord) int { + return callStart(a).Compare(callStart(b)) + }) + return records, nil } +// callStart returns the start of the call of record, or the zero time when its +// date is not valid. +func callStart(record usageRecord) time.Time { + start, err := time.Parse(time.RFC3339Nano, record.Date) + if err != nil { + return time.Time{} + } + + return start +} + // usedPercents returns the used percent of each quota window of record, by // header name. func usedPercents(record usageRecord) map[string]float64 { @@ -476,7 +546,7 @@ func timeText(records []usageRecord) string { wall := events[len(events)-1].at.Sub(events[0].at).Seconds() slices.Sort(seconds) - return fmt.Sprintf("time: %.0f s in all, %.1f s per call (median %.1f, max %.1f), calls at once: at most %d, %.1f on average", wall, total/float64(len(seconds)), seconds[len(seconds)/2], seconds[len(seconds)-1], most, total/max(wall, 1e-9)) + return fmt.Sprintf("time: %.0f s in all, %.1f calls per minute, %.1f s per call (median %.1f, max %.1f), calls at once: at most %d, %.1f on average", wall, 60*float64(len(seconds))/max(wall, 1e-9), total/float64(len(seconds)), seconds[len(seconds)/2], seconds[len(seconds)-1], most, total/max(wall, 1e-9)) } // quotaText returns the quota headers of record, sorted by name.