Skip to content

Commit 5b31b9d

Browse files
authored
Merge pull request #13 from hallelx2/feat/agentic-retrieval
pkg/retrieval: add agentic-navigation strategy (Phase 1.4)
2 parents 2a672cb + 4f7b5d8 commit 5b31b9d

8 files changed

Lines changed: 964 additions & 9 deletions

File tree

cmd/engine/main.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"errors"
1111
"flag"
1212
"fmt"
13+
"io"
1314
"log/slog"
1415
"net/http"
1516
"os"
@@ -87,7 +88,7 @@ func run() error {
8788
if err != nil {
8889
return fmt.Errorf("init llm: %w", err)
8990
}
90-
strategy := buildStrategy(cfg.Retrieval, llmClient)
91+
strategy := buildStrategy(cfg.Retrieval, llmClient, store)
9192

9293
// Wrap with caching if enabled.
9394
if cfg.Retrieval.Cache.Enabled {
@@ -252,17 +253,39 @@ func buildLLM(c config.LLMConfig) (llmgate.Client, error) {
252253
}
253254
}
254255

255-
func buildStrategy(c config.RetrievalConfig, client llmgate.Client) retrieval.Strategy {
256+
func buildStrategy(c config.RetrievalConfig, client llmgate.Client, store storage.Storage) retrieval.Strategy {
256257
switch c.Strategy {
257258
case "single-pass":
258259
return retrieval.NewSinglePass(client)
259260
case "chunked-tree":
260261
return retrieval.NewChunkedTree(client)
262+
case "agentic":
263+
a := retrieval.NewAgentic(client, storageFetcher{s: store})
264+
if c.Agentic.MaxHops > 0 {
265+
a.MaxHops = c.Agentic.MaxHops
266+
}
267+
a.ModelOverride = c.Agentic.Model
268+
return a
261269
default:
262270
return retrieval.NewChunkedTree(client)
263271
}
264272
}
265273

274+
// storageFetcher adapts a storage.Storage to retrieval.ContentFetcher.
275+
// The agentic strategy reads section bodies one at a time, so we
276+
// materialize the full reader contents into a []byte here rather than
277+
// streaming — section bodies are typically a few KB.
278+
type storageFetcher struct{ s storage.Storage }
279+
280+
func (sf storageFetcher) Get(ctx context.Context, ref string) ([]byte, error) {
281+
rc, _, err := sf.s.Get(ctx, ref)
282+
if err != nil {
283+
return nil, err
284+
}
285+
defer rc.Close()
286+
return io.ReadAll(rc)
287+
}
288+
266289
// buildTLSConfig returns a *tls.Config when direct TLS is enabled, or nil
267290
// when the engine should serve plaintext (behind a proxy). Returning nil
268291
// leaves http.Server's TLSConfig unset, which is exactly what ListenAndServe

cmd/server/main.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"errors"
1919
"flag"
2020
"fmt"
21+
"io"
2122
"log/slog"
2223
"net/http"
2324
"os"
@@ -131,7 +132,7 @@ func run() error {
131132
if err != nil {
132133
return fmt.Errorf("init llm: %w", err)
133134
}
134-
strategy := buildStrategy(cfg.Engine.Retrieval, llmClient)
135+
strategy := buildStrategy(cfg.Engine.Retrieval, llmClient, store)
135136

136137
// Wrap with caching if enabled in engine config.
137138
if cfg.Engine.Retrieval.Cache.Enabled {
@@ -328,17 +329,39 @@ func buildLLM(c enginecfg.LLMConfig) (llmgate.Client, error) {
328329
}
329330
}
330331

331-
func buildStrategy(c enginecfg.RetrievalConfig, client llmgate.Client) retrieval.Strategy {
332+
func buildStrategy(c enginecfg.RetrievalConfig, client llmgate.Client, store storage.Storage) retrieval.Strategy {
332333
switch c.Strategy {
333334
case "single-pass":
334335
return retrieval.NewSinglePass(client)
335336
case "chunked-tree":
336337
return retrieval.NewChunkedTree(client)
338+
case "agentic":
339+
a := retrieval.NewAgentic(client, storageFetcher{s: store})
340+
if c.Agentic.MaxHops > 0 {
341+
a.MaxHops = c.Agentic.MaxHops
342+
}
343+
a.ModelOverride = c.Agentic.Model
344+
return a
337345
default:
338346
return retrieval.NewChunkedTree(client)
339347
}
340348
}
341349

350+
// storageFetcher adapts a storage.Storage to retrieval.ContentFetcher.
351+
// The agentic strategy reads section bodies one at a time, so we
352+
// materialize the full reader contents into a []byte here rather than
353+
// streaming — section bodies are typically a few KB.
354+
type storageFetcher struct{ s storage.Storage }
355+
356+
func (sf storageFetcher) Get(ctx context.Context, ref string) ([]byte, error) {
357+
rc, _, err := sf.s.Get(ctx, ref)
358+
if err != nil {
359+
return nil, err
360+
}
361+
defer rc.Close()
362+
return io.ReadAll(rc)
363+
}
364+
342365
func buildTLSConfig(c config.TLSConfig) *tls.Config {
343366
if !c.Enabled() {
344367
return nil

pkg/config/config.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ type GeminiBlock struct {
188188
type RetrievalConfig struct {
189189
Strategy string `yaml:"strategy"`
190190
ChunkedTree ChunkedTreeBlock `yaml:"chunked_tree"`
191+
Agentic AgenticBlock `yaml:"agentic"`
191192
Cache CacheBlock `yaml:"cache"`
192193
}
193194

@@ -212,6 +213,23 @@ type ChunkedTreeBlock struct {
212213
IncludeSiblingBreadcrumb bool `yaml:"include_sibling_breadcrumbs"`
213214
}
214215

216+
// AgenticBlock configures the agentic-navigation strategy.
217+
//
218+
// The agentic loop trades sequential latency for the ability to handle
219+
// arbitrarily large trees: the model issues outline/expand/read actions
220+
// until it picks a final set of section IDs or hits MaxHops.
221+
type AgenticBlock struct {
222+
// MaxHops caps the number of LLM turns one query consumes, counting
223+
// the terminal "done" turn. Default: 6.
224+
MaxHops int `yaml:"max_hops"`
225+
226+
// Model optionally overrides the budget's model for navigation
227+
// turns. Empty means use the budget's model. Useful when the
228+
// retrieval engine wants the navigation loop on a fast/cheap
229+
// model while answering is on a stronger one.
230+
Model string `yaml:"model"`
231+
}
232+
215233
// LogConfig configures logging.
216234
type LogConfig struct {
217235
Level string `yaml:"level"`
@@ -244,6 +262,9 @@ func Default() Config {
244262
MaxParallelCalls: 8,
245263
IncludeSiblingBreadcrumb: true,
246264
},
265+
Agentic: AgenticBlock{
266+
MaxHops: 6,
267+
},
247268
Cache: CacheBlock{
248269
Enabled: true,
249270
MaxEntries: 1024,
@@ -352,6 +373,14 @@ func applyEnvOverrides(c *Config) {
352373
if v := os.Getenv("VLE_TLS_KEY_FILE"); v != "" {
353374
c.Server.TLS.KeyFile = v
354375
}
376+
if v := os.Getenv("VLE_RETRIEVAL_AGENTIC_MAX_HOPS"); v != "" {
377+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
378+
c.Retrieval.Agentic.MaxHops = n
379+
}
380+
}
381+
if v := os.Getenv("VLE_RETRIEVAL_AGENTIC_MODEL"); v != "" {
382+
c.Retrieval.Agentic.Model = v
383+
}
355384
// Ingest / HyDE knobs. Booleans accept the usual truthy strings —
356385
// kept narrow so a typo doesn't silently flip the flag.
357386
if v := os.Getenv("VLE_INGEST_HYDE_ENABLED"); v != "" {
@@ -428,11 +457,15 @@ func (c Config) Validate() error {
428457
}
429458

430459
switch c.Retrieval.Strategy {
431-
case "single-pass", "chunked-tree":
460+
case "single-pass", "chunked-tree", "agentic":
432461
default:
433462
return fmt.Errorf("unknown retrieval.strategy: %q", c.Retrieval.Strategy)
434463
}
435464

465+
if c.Retrieval.Agentic.MaxHops < 0 {
466+
return fmt.Errorf("retrieval.agentic.max_hops must be >= 0, got %d", c.Retrieval.Agentic.MaxHops)
467+
}
468+
436469
if c.Server.TLS.CertFile != "" && c.Server.TLS.KeyFile == "" {
437470
return errors.New("server.tls.key_file is required when cert_file is set")
438471
}

0 commit comments

Comments
 (0)