agent build
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user