agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

View File

@@ -35,7 +35,10 @@ import (
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/knowledge"
"github.com/krow/krow-backend/go-api/internal/runtime"
"github.com/krow/krow-backend/go-api/internal/service"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// Server binds the router, the pool, authentication and the lifecycle together.
@@ -46,10 +49,26 @@ type Server struct {
definitions *service.DefinitionsService
workflows *service.WorkflowService
suggestions *service.SuggestionsService
log *slog.Logger
http *http.Server
started time.Time
endpoints int
// The agent runtime. Nil when no model credential is configured — the run
// routes are then not registered at all, so the deployment answers 404
// ("this deployment does not serve agents") rather than 500 ("this
// deployment is broken"). Only one of those is true.
agents *runtime.Engine
runs *runtime.RunReader
version string
// toolCatalogue is the tool set an agent author may choose from.
//
// Built whether or not a model credential exists: the catalogue describes
// what the tools ARE, and a deployment that cannot currently run agents can
// still be one where somebody is authoring them.
toolCatalogue []tools.ToolInfo
log *slog.Logger
http *http.Server
started time.Time
endpoints int
// The authentication surface. sessions owns the lifecycle, users is the
// read side of the users table, credentials verifies a password against it,
@@ -78,6 +97,32 @@ type serverOptions struct {
perEmail int
perAddress int
loginWindow time.Duration
// agents replaces the engine New would otherwise build from configuration.
//
// For tests, and only for tests: production wires a real gateway from a
// real key, and an option that let a deployment substitute the runtime
// would be a way to run agents against something nobody configured.
agents *runtime.Engine
// version is the build identifier, stamped into the binary at link time.
// Not configuration: it describes the artefact, not the deployment, and an
// environment variable could disagree with the code it claims to describe.
version string
}
// WithBuildVersion records which build this is.
//
// Unlike the options above this one is for production. Without it there is no
// way to answer "did my deploy land?" — the symptom is pushing an image,
// redeploying, and having nobody, including the operator, able to tell whether
// the running process is the new one.
func WithBuildVersion(v string) Option {
return func(o *serverOptions) {
if v != "" {
o.version = v
}
}
}
// WithSessionPolicy overrides the session lifetimes. For tests that need to
@@ -99,6 +144,20 @@ func WithClock(now func() time.Time) Option {
//
// perEmail bounds attempts against one account; perAddress bounds attempts from
// one client address across all accounts. Both are consulted on every attempt.
// WithAgentEngine substitutes the agent runtime.
//
// The seam that lets the HTTP layer be tested without a model credential —
// which matters more than it sounds, because the alternative is that the run
// endpoint is the one part of this service no test can reach until somebody
// pays for a key.
//
// It does not weaken anything: the engine still loads agents through the same
// loader, still runs them under the same budgets, and still authorizes through
// the same principal. Only the model behind it changes.
func WithAgentEngine(e *runtime.Engine) Option {
return func(o *serverOptions) { o.agents = e }
}
func WithLoginRateLimit(perEmail, perAddress int, window time.Duration) Option {
return func(o *serverOptions) {
o.perEmail, o.perAddress, o.loginWindow = perEmail, perAddress, window
@@ -117,6 +176,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
perEmail: loginAttemptLimit,
perAddress: loginAddressLimit,
loginWindow: loginAttemptWindow,
version: "unknown",
}
for _, opt := range opts {
opt(&o)
@@ -131,10 +191,11 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
users := auth.NewPGUserStore(database.Pool)
s := &Server{
cfg: cfg, db: database, log: log,
version: o.version,
api: service.NewRegistry(database.Pool),
definitions: service.NewDefinitions(database.Pool),
workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
suggestions: service.NewSuggestions(),
suggestions: service.NewSuggestions(database.Pool),
started: o.now(),
sessions: sessions,
users: users,
@@ -144,10 +205,38 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
now: o.now,
}
// The agent runtime, wired only when there is a model to reach.
//
// Registering the routes without a credential would accept runs and fail
// every one of them at the gateway — an outage shaped like a feature. A
// deployment without a key is a deployment that does not serve agents, and
// saying so at boot is kinder than saying it once per request.
switch {
case o.agents != nil:
s.agents = o.agents
s.runs = runtime.NewRunReader(database.Pool)
case cfg.Model.APIKey != "":
s.agents = runtime.NewModelEngine(database.Pool, *cfg)
s.runs = runtime.NewRunReader(database.Pool)
}
// Built the same way the runtime builds its own, so the list an author is
// offered is the list their agent will actually have.
toolRegistry := runtime.DefaultTools(
database.Pool,
knowledge.NewRetriever(database.Pool, runtime.NewEmbedder(*cfg)),
)
s.toolCatalogue = toolRegistry.Catalogue()
// So a definition naming a tool that does not exist is refused at publish
// rather than becoming an agent that silently cannot do what it claims.
s.definitions = s.definitions.WithToolCheck(toolRegistry.Known)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux)
s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux) +
s.routeRuns(mux) + s.routeVersion(mux) + s.routeTools(mux)
handler := jsonErrors(mux)
// Authentication sits where devOrgMiddleware used to, so every route below
@@ -227,6 +316,37 @@ type healthResponse struct {
Status string `json:"status"`
}
// routeTools lists the tools an agent author may choose from.
//
// The frontend's agent editor had no tools field at all, so an authored agent
// carried none and could talk without being able to look anything up. Serving
// the catalogue rather than hard-coding it in the UI keeps one list: a tool
// added or renamed here cannot leave a stale copy behind in a form.
func (s *Server) routeTools(mux *http.ServeMux) int {
mux.HandleFunc("GET /api/v1/tools", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, envelope{Data: s.toolCatalogue})
})
return 1
}
// routeVersion exposes the build identifier to an authenticated caller.
//
// Under /api/v1 rather than on /health deliberately. /health is public, and it
// already withholds its detail from the internet for the reason given above; a
// build identifier is exactly the kind of thing that tells an unauthenticated
// reader which source to go and read. An operator has a session, so this is
// where an operator can reach it and a stranger cannot.
func (s *Server) routeVersion(mux *http.ServeMux) int {
mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, envelope{Data: map[string]any{
"version": s.version,
"env": s.cfg.AppEnv,
"endpoints": s.endpoints,
}})
})
return 1
}
// handleHealth reports whether this instance should be sent traffic.
//
// 200 "ok" serving normally
@@ -312,6 +432,23 @@ func (r *statusRecorder) WriteHeader(code int) {
r.ResponseWriter.WriteHeader(code)
}
// Flush forwards to the writer underneath.
//
// A wrapper that embeds http.ResponseWriter inherits Write and WriteHeader and
// SILENTLY DROPS every optional interface the real writer implements — Flusher
// among them. Nothing errors: the handler simply asks "can this flush?", is
// told no, and takes whatever fallback it has.
//
// That is exactly how it presented. The SSE endpoint answered ordinary JSON,
// correctly and completely, with no error anywhere — because two middlewares
// deep the writer had stopped being a Flusher and the streaming path politely
// declined to stream.
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func requestLogger(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -377,6 +514,13 @@ func (i *interceptor) Write(b []byte) (int, error) {
return i.ResponseWriter.Write(b)
}
// Flush forwards to the writer underneath. See statusRecorder.Flush.
func (i *interceptor) Flush() {
if f, ok := i.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// recoverer turns a panic into a logged 500 rather than a dropped connection.
func recoverer(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {