Skip to content

Commit f8060ea

Browse files
feat: reload development agents from config changes
1 parent dda6f61 commit f8060ea

4 files changed

Lines changed: 206 additions & 28 deletions

File tree

‎cmd/golanggraph/cli_test.go‎

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -791,14 +791,65 @@ func TestDev_ValidatesAndAppliesTheLogLevel(t *testing.T) {
791791
assert.Contains(t, err.Error(), "invalid log level")
792792
}
793793

794-
func TestDev_SaysHotReloadIsNotImplemented(t *testing.T) {
795-
// Point at an occupied port so the run stops right after the notice.
796-
var out bytes.Buffer
797-
_ = runServer(context.Background(), &out, serverOptions{
798-
Host: "127.0.0.1", Port: occupiedPort(t), Dev: true, HotReload: true,
794+
func TestDev_HotReloadRequiresAgentConfig(t *testing.T) {
795+
err := runServer(context.Background(), &bytes.Buffer{}, serverOptions{
796+
Host: "127.0.0.1", Port: freePort(t), Dev: true, HotReload: true,
799797
})
800798

801-
assert.Contains(t, out.String(), "hot-reload is not implemented")
799+
require.Error(t, err)
800+
assert.Contains(t, err.Error(), "requires --agent-config")
801+
}
802+
803+
func TestDev_HotReloadReplacesConfiguredAgents(t *testing.T) {
804+
path := writeTestFile(t, t.TempDir(), "agent.yaml", "id: initial\n"+validAgentYAML)
805+
port := freePort(t)
806+
ctx, cancel := context.WithCancel(context.Background())
807+
defer cancel()
808+
809+
var out bytes.Buffer
810+
errCh := make(chan error, 1)
811+
go func() {
812+
errCh <- runServer(ctx, &out, serverOptions{
813+
Host: "127.0.0.1", Port: port, Dev: true, HotReload: true, AgentConfig: path,
814+
})
815+
}()
816+
817+
endpoint := fmt.Sprintf("http://127.0.0.1:%d/api/v1/agents", port)
818+
waitForAgentIDs(t, endpoint, "initial")
819+
820+
replacement := "id: replacement\n" + validAgentYAML
821+
require.NoError(t, os.WriteFile(path, []byte(replacement), 0o600))
822+
waitForAgentIDs(t, endpoint, "replacement")
823+
824+
cancel()
825+
require.NoError(t, <-errCh)
826+
assert.Contains(t, out.String(), "Reloaded agent configuration")
827+
}
828+
829+
func waitForAgentIDs(t *testing.T, endpoint string, wanted string) {
830+
t.Helper()
831+
deadline := time.Now().Add(5 * time.Second)
832+
for time.Now().Before(deadline) {
833+
response, err := http.Get(endpoint) // #nosec G107 -- local server started by this test.
834+
if err == nil {
835+
var payload struct {
836+
Agents []struct {
837+
ID string `json:"id"`
838+
} `json:"agents"`
839+
}
840+
decodeErr := json.NewDecoder(response.Body).Decode(&payload)
841+
_ = response.Body.Close()
842+
if decodeErr == nil {
843+
for _, configured := range payload.Agents {
844+
if configured.ID == wanted {
845+
return
846+
}
847+
}
848+
}
849+
}
850+
time.Sleep(25 * time.Millisecond)
851+
}
852+
t.Fatalf("agent %q did not appear at %s before timeout", wanted, endpoint)
802853
}
803854

804855
// ---------------------------------------------------------------------------

‎cmd/golanggraph/main.go‎

Lines changed: 121 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"syscall"
2323
"time"
2424

25+
"github.com/fsnotify/fsnotify"
2526
"github.com/sirupsen/logrus"
2627
"github.com/spf13/cobra"
2728
"github.com/spf13/viper"
@@ -282,9 +283,9 @@ var devCmd = &cobra.Command{
282283
Short: "Start a development server",
283284
Long: `Start a development server for testing and debugging agents.
284285
285-
Includes an interactive debugging interface and an agent playground. Hot-reload
286-
is not implemented: --hot-reload only reports that, so restart the server to
287-
pick up changes.`,
286+
Includes an interactive debugging interface and an agent playground. With
287+
--hot-reload and --agent-config, the server atomically replaces configured
288+
agents whenever that configuration file changes.`,
288289
RunE: func(cmd *cobra.Command, args []string) error {
289290
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
290291
defer stop()
@@ -427,7 +428,7 @@ func init() {
427428
devCmd.Flags().StringP("host", "H", "localhost", "Host to bind to")
428429
devCmd.Flags().IntP("port", "p", 8080, "Port to bind to")
429430
devCmd.Flags().String("agent-config", "", "Agent configuration file")
430-
devCmd.Flags().Bool("hot-reload", true, "Enable hot-reload (not implemented)")
431+
devCmd.Flags().Bool("hot-reload", false, "Reload --agent-config when its file changes")
431432
devCmd.Flags().Bool("debug", true, "Enable the server's development mode (debug interface and playground)")
432433
devCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)")
433434

@@ -530,8 +531,8 @@ type serverOptions struct {
530531
// AgentConfig is an optional agent configuration file whose agents are
531532
// created on the server before it starts serving.
532533
AgentConfig string
533-
// HotReload is the dev command's --hot-reload flag. File watching is not
534-
// implemented; the flag is reported honestly rather than acted on.
534+
// HotReload watches --agent-config and atomically replaces its agents when
535+
// its file changes.
535536
HotReload bool
536537
// LogLevel is applied to the server's logger.
537538
LogLevel string
@@ -588,29 +589,35 @@ func runServer(ctx context.Context, out io.Writer, opts serverOptions) error {
588589

589590
// --agent-config used to be declared and never read. Load it for real.
590591
if opts.AgentConfig != "" {
591-
configs, err := loadAgentConfigs(opts.AgentConfig)
592-
if err != nil {
593-
return fmt.Errorf("agent config %s: %w", opts.AgentConfig, err)
594-
}
595-
for _, cfg := range configs {
596-
if _, err := agentManager.CreateAgent(cfg.toAgentConfig()); err != nil {
597-
return fmt.Errorf("failed to create agent %s: %w", cfg.Name, err)
598-
}
599-
_, _ = fmt.Fprintf(out, "Loaded agent %s (%s)\n", cfg.Name, cfg.Type)
592+
if err := replaceAgentsFromConfig(out, agentManager, opts.AgentConfig); err != nil {
593+
return err
600594
}
601595
}
602596

603-
if opts.HotReload {
604-
// The previous implementation printed "Hot-reload enabled - watching
605-
// for changes..." next to a comment saying the watcher "would go here".
606-
_, _ = fmt.Fprintln(out, "Note: hot-reload is not implemented; restart the server to pick up changes")
607-
}
608-
609597
// Fail before announcing success if the address cannot be bound.
610598
if err := checkAddressAvailable(opts.Host, opts.Port); err != nil {
611599
return err
612600
}
613601

602+
watchCtx, stopWatching := context.WithCancel(ctx)
603+
var waitForWatcher func()
604+
defer func() {
605+
stopWatching()
606+
if waitForWatcher != nil {
607+
waitForWatcher()
608+
}
609+
}()
610+
if opts.HotReload {
611+
if opts.AgentConfig == "" {
612+
return fmt.Errorf("--hot-reload requires --agent-config")
613+
}
614+
wait, err := watchAgentConfig(watchCtx, out, agentManager, opts.AgentConfig)
615+
if err != nil {
616+
return err
617+
}
618+
waitForWatcher = wait
619+
}
620+
614621
errCh := make(chan error, 1)
615622
go func() { errCh <- srv.Start() }()
616623

@@ -643,6 +650,99 @@ func runServer(ctx context.Context, out io.Writer, opts serverOptions) error {
643650
return nil
644651
}
645652

653+
// replaceAgentsFromConfig parses a complete replacement set before atomically
654+
// swapping it into the manager. It is shared by the initial load and reload
655+
// path so both reject the same malformed configuration.
656+
func replaceAgentsFromConfig(out io.Writer, manager *server.AgentManager, configPath string) error {
657+
configs, err := loadAgentConfigs(configPath)
658+
if err != nil {
659+
return fmt.Errorf("agent config %s: %w", configPath, err)
660+
}
661+
662+
agentConfigs := make([]*agent.AgentConfig, 0, len(configs))
663+
for _, cfg := range configs {
664+
agentConfigs = append(agentConfigs, cfg.toAgentConfig())
665+
}
666+
if err := manager.ReplaceAgents(agentConfigs); err != nil {
667+
return fmt.Errorf("agent config %s: %w", configPath, err)
668+
}
669+
for _, cfg := range configs {
670+
_, _ = fmt.Fprintf(out, "Loaded agent %s (%s)\n", cfg.Name, cfg.Type)
671+
}
672+
return nil
673+
}
674+
675+
// watchAgentConfig watches both the configuration file's directory and its
676+
// filename. Watching the directory is important because editors commonly save
677+
// by replacing a temporary file, which invalidates a file-only watch.
678+
func watchAgentConfig(ctx context.Context, out io.Writer, manager *server.AgentManager, configPath string) (func(), error) {
679+
absPath, err := filepath.Abs(configPath)
680+
if err != nil {
681+
return nil, fmt.Errorf("resolve agent config for hot-reload: %w", err)
682+
}
683+
absPath = filepath.Clean(absPath)
684+
685+
watcher, err := fsnotify.NewWatcher()
686+
if err != nil {
687+
return nil, fmt.Errorf("create hot-reload watcher: %w", err)
688+
}
689+
if err := watcher.Add(filepath.Dir(absPath)); err != nil {
690+
_ = watcher.Close()
691+
return nil, fmt.Errorf("watch agent config directory: %w", err)
692+
}
693+
694+
_, _ = fmt.Fprintf(out, "Watching agent config for changes: %s\n", absPath)
695+
done := make(chan struct{})
696+
go func() {
697+
defer close(done)
698+
defer func() { _ = watcher.Close() }()
699+
var debounce <-chan time.Time
700+
var timer *time.Timer
701+
for {
702+
select {
703+
case <-ctx.Done():
704+
if timer != nil {
705+
timer.Stop()
706+
}
707+
return
708+
case event, ok := <-watcher.Events:
709+
if !ok {
710+
return
711+
}
712+
if filepath.Clean(event.Name) != absPath || event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
713+
continue
714+
}
715+
if timer == nil {
716+
timer = time.NewTimer(150 * time.Millisecond)
717+
debounce = timer.C
718+
continue
719+
}
720+
if !timer.Stop() {
721+
select {
722+
case <-timer.C:
723+
default:
724+
}
725+
}
726+
timer.Reset(150 * time.Millisecond)
727+
case <-debounce:
728+
timer = nil
729+
debounce = nil
730+
if err := replaceAgentsFromConfig(out, manager, absPath); err != nil {
731+
_, _ = fmt.Fprintf(out, "Hot-reload failed; keeping existing agents: %v\n", err)
732+
continue
733+
}
734+
_, _ = fmt.Fprintln(out, "Reloaded agent configuration")
735+
case err, ok := <-watcher.Errors:
736+
if !ok {
737+
return
738+
}
739+
_, _ = fmt.Fprintf(out, "Hot-reload watcher error: %v\n", err)
740+
}
741+
}
742+
}()
743+
return func() { <-done }, nil
744+
}
745+
646746
// checkAddressAvailable reports whether the server can bind host:port. Without
647747
// this the CLI announced a listening server whose bind had already failed.
648748
func checkAddressAvailable(host string, port int) error {

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/UnicoLab/GoLangGraph
33
go 1.23.0
44

55
require (
6+
github.com/fsnotify/fsnotify v1.8.0
67
github.com/go-redis/redis/v8 v8.11.5
78
github.com/google/uuid v1.6.0
89
github.com/gorilla/mux v1.8.1
@@ -24,7 +25,6 @@ require (
2425
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
2526
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
2627
github.com/dlclark/regexp2 v1.10.0 // indirect
27-
github.com/fsnotify/fsnotify v1.8.0 // indirect
2828
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
2929
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3030
github.com/pelletier/go-toml/v2 v2.2.3 // indirect

‎pkg/server/server.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,33 @@ func (am *AgentManager) CreateAgent(config *agent.AgentConfig) (agent.Agent, err
16931693
return agentInstance, nil
16941694
}
16951695

1696+
// ReplaceAgents atomically replaces the agents managed by am. The replacement
1697+
// is built and validated before the live map is swapped, so a malformed reload
1698+
// leaves the currently serving agents available.
1699+
func (am *AgentManager) ReplaceAgents(configs []*agent.AgentConfig) error {
1700+
replacement := make(map[string]agent.Agent, len(configs))
1701+
for _, config := range configs {
1702+
if config == nil {
1703+
return fmt.Errorf("agent configuration is nil")
1704+
}
1705+
if err := config.Validate(); err != nil {
1706+
return fmt.Errorf("invalid agent %q: %w", config.ID, err)
1707+
}
1708+
if config.ID == "" {
1709+
return fmt.Errorf("agent %q has an empty id", config.Name)
1710+
}
1711+
if _, exists := replacement[config.ID]; exists {
1712+
return fmt.Errorf("duplicate agent id %q", config.ID)
1713+
}
1714+
replacement[config.ID] = agent.NewAgent(config, am.llmManager, am.toolRegistry)
1715+
}
1716+
1717+
am.mu.Lock()
1718+
am.agents = replacement
1719+
am.mu.Unlock()
1720+
return nil
1721+
}
1722+
16961723
// GetAgent retrieves an agent by ID
16971724
func (am *AgentManager) GetAgent(id string) (agent.Agent, bool) {
16981725
am.mu.RLock()

0 commit comments

Comments
 (0)