@@ -1,48 +0,0 @@
|
||||
# pkg/agent
|
||||
|
||||
LLM agent orchestration framework.
|
||||
|
||||
## Agent construction
|
||||
|
||||
```go
|
||||
agent := agent.NewAgent(
|
||||
"agent-name",
|
||||
"System instructions here",
|
||||
agent.WithTools(tool1, tool2),
|
||||
agent.WithHandoffs(otherAgent),
|
||||
agent.WithModel(model),
|
||||
)
|
||||
```
|
||||
|
||||
Functional options: `WithTools`, `WithHandoffs`, `WithInstructions`, `WithModel`, `WithModelSettings`, `WithMCPServers`, `WithInputGuardrails`, `WithOutputGuardrails`, `WithApproval`, `WithSession`.
|
||||
|
||||
## Execution
|
||||
|
||||
```go
|
||||
result, err := agent.Run(ctx, messages)
|
||||
result.FinalMessage().Text() // final output
|
||||
result.LastAgent // agent that produced the result
|
||||
```
|
||||
|
||||
Typed output via `RunTyped[T](ctx, agent, messages)` — validates against JSON Schema.
|
||||
|
||||
## Tool interface
|
||||
|
||||
```go
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() jsonschema.Schema
|
||||
Execute(ctx context.Context, input json.RawMessage) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
## Agent-as-tool
|
||||
|
||||
`agent.AsTool(name, description)` wraps an agent as a tool for composition.
|
||||
|
||||
## Limits
|
||||
|
||||
- Max turns: 10 (default)
|
||||
- Max tool depth: 16 (default)
|
||||
- Depth tracking prevents infinite recursion in handoffs
|
||||
@@ -1,82 +0,0 @@
|
||||
# AGENTS.md — prb CLI
|
||||
|
||||
## Overview
|
||||
|
||||
`prb` is the Probo CLI built with [cobra](https://github.com/spf13/cobra). Entry point: `cmd/prb/main.go`.
|
||||
|
||||
## Package layout
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `cmd/prb` | Binary entry point — creates `Factory`, root command, and runs it |
|
||||
| `pkg/cmd/root` | Root command — registers all top-level subcommands |
|
||||
| `pkg/cmd/<resource>` | Command group (e.g. `risk`, `framework`, `webhook`) — wires subcommands |
|
||||
| `pkg/cmd/<resource>/<verb>` | Leaf command (e.g. `risk/create`, `risk/list`) — owns the `RunE` |
|
||||
| `pkg/cmd/cmdutil` | Shared helpers: `Factory`, flag validators, table/JSON output, time formatting |
|
||||
| `pkg/cmd/iostreams` | Terminal I/O abstraction (stdout, stderr, color, interactivity) |
|
||||
| `pkg/cli/api` | GraphQL client (`Client`) and generic pagination (`Paginate[T]`) |
|
||||
| `pkg/cli/config` | Config file management (hosts, tokens, default org) |
|
||||
|
||||
## Adding a new resource command
|
||||
|
||||
1. Create `pkg/cmd/<resource>/<resource>.go` with a `NewCmd<Resource>(f *cmdutil.Factory) *cobra.Command` that groups the subcommands.
|
||||
2. Create a subpackage per verb (`list`, `create`, `view`, `update`, `delete`) each exporting `NewCmd<Verb>(f *cmdutil.Factory) *cobra.Command`.
|
||||
3. Register the group command in `pkg/cmd/root/root.go`.
|
||||
|
||||
## Command structure pattern
|
||||
|
||||
Every leaf command follows this pattern:
|
||||
|
||||
```go
|
||||
package verb
|
||||
|
||||
func NewCmd<Verb>(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagFoo string
|
||||
// ...
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "<verb>",
|
||||
Short: "One-line description",
|
||||
Aliases: []string{"..."}, // optional, e.g. "ls" for list
|
||||
Example: ` prb <resource> <verb> ...`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// 1. Validate output flags (for list commands)
|
||||
// 2. Load config, get host + token
|
||||
// 3. Create api.Client
|
||||
// 4. Resolve --org (flag → config default)
|
||||
// 5. Interactive prompts if IOStreams.IsInteractive() and flags are missing
|
||||
// 6. Call API via client.Do(query, variables)
|
||||
// 7. Output: JSON via cmdutil.PrintJSON or table via cmdutil.NewTable
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
|
||||
// ... more flags ...
|
||||
|
||||
return cmd
|
||||
}
|
||||
```
|
||||
|
||||
## Key conventions
|
||||
|
||||
- **GraphQL queries/mutations** are `const` strings declared at package level in the leaf command file.
|
||||
- **Response types** are unexported structs in the leaf command file, shaped to match the GraphQL response.
|
||||
- **Organization resolution**: every command that needs an org checks `--org` flag first, then falls back to `hc.Organization` from config. If both are empty, return an error telling the user to pass `--org` or run `prb auth login`.
|
||||
- **Interactive prompts** use `github.com/charmbracelet/huh`. Gate them behind `f.IOStreams.IsInteractive()`. Always support full non-interactive use via flags.
|
||||
- **Output format**: list commands support `--output json|table` via `cmdutil.AddOutputFlag` / `cmdutil.ValidateOutputFlag`. Default is table.
|
||||
- **Pagination**: list commands use `api.Paginate[T]` with a `--limit` / `-L` flag (default 30). Show "Showing X of Y" on stderr when results are truncated.
|
||||
- **Table output**: use `cmdutil.NewTable("COL", ...).Rows(rows...)`.
|
||||
- **View commands** print detailed formatted output with lipgloss-styled labels and sections. They support `--output json|table` like list commands. Use `lipgloss.NewStyle()` for bold titles and dimmed labels.
|
||||
- **Create/update/delete** commands print a single confirmation line to stdout (e.g. `"Created risk %s (%s)\n"`).
|
||||
- **Delete commands** prompt for confirmation interactively; skip the prompt when `--yes` / `-y` is passed.
|
||||
- **Flag naming**: use kebab-case (`--order-by`, `--inherent-likelihood`). Use `StringVar` / `IntVar` (not positional args) for all inputs.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- CLI framework: `github.com/spf13/cobra`
|
||||
- Interactive prompts: `github.com/charmbracelet/huh`
|
||||
- Styled terminal output: `github.com/charmbracelet/lipgloss`
|
||||
- All other dependencies follow the root AGENTS.md (same module, same style rules)
|
||||
@@ -1,164 +0,0 @@
|
||||
# pkg/coredata
|
||||
|
||||
All raw SQL lives here — never in service or handler packages.
|
||||
|
||||
## Entity files
|
||||
|
||||
One file per entity (`asset.go`, `vendor.go`, etc.), plus optional companion `_filter.go` and `_order_field.go` files when needed. No codegen — everything is hand-written.
|
||||
|
||||
Entity structs do **not** have a `TenantID` field — the `tenant_id` column is provided by the `Scoper` (via `scope.GetTenantID()`) at query time, not stored on the Go struct.
|
||||
|
||||
## SQL query pattern
|
||||
|
||||
Every query uses raw SQL with `pgx.StrictNamedArgs` and scope injection via `fmt.Sprintf`:
|
||||
|
||||
```go
|
||||
q := `
|
||||
SELECT id, name, created_at, updated_at
|
||||
FROM assets
|
||||
WHERE
|
||||
%s
|
||||
AND id = @asset_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"asset_id": assetID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
```
|
||||
|
||||
## Scoper interface
|
||||
|
||||
Every Load/Insert/Update/Delete method takes a `Scoper` parameter for tenant isolation:
|
||||
- `Scope` — adds `tenant_id = @tenant_id` WHERE clause
|
||||
- `NoScope` — returns `TRUE` (for cross-tenant operations)
|
||||
|
||||
Insert uses `scope.GetTenantID()` for the tenant_id value.
|
||||
|
||||
## Method patterns
|
||||
|
||||
| Method | Receiver | Returns | Notes |
|
||||
|--------|----------|---------|-------|
|
||||
| `LoadByID` | `*Entity` | `error` | Assigns into receiver via `*e = entity` |
|
||||
| `LoadAllBy*` | `*Entities` (slice type) | `error` | Paginated with `page.Cursor[OrderField]` |
|
||||
| `CountBy*` | `*Entities` | `(int, error)` | Uses `COUNT(id)` |
|
||||
| `Insert` | `*Entity` | `error` | Uses `scope.GetTenantID()` for tenant_id |
|
||||
| `Update` | `*Entity` | `error` | Uses `RETURNING` to reassign receiver |
|
||||
| `Delete` | `*Entity` | `error` | — |
|
||||
| `CursorKey` | `*Entity` | `page.CursorKey` | Switch on OrderField, panic on unknown |
|
||||
| `AuthorizationAttributes` | `*Entity` | `(map[string]string, error)` | Returns org/tenant IDs for authz |
|
||||
|
||||
## Row collection
|
||||
|
||||
- Single row: `pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[T])`
|
||||
- Multiple rows: `pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[T])`
|
||||
- Check `pgx.ErrNoRows` → return `ErrResourceNotFound`
|
||||
|
||||
## Sentinel errors
|
||||
|
||||
Defined in `errors.go`:
|
||||
- `ErrResourceNotFound` — row not found (`pgx.ErrNoRows`)
|
||||
- `ErrResourceAlreadyExists` — unique constraint violation
|
||||
- `ErrResourceInUse` — foreign key constraint prevents deletion
|
||||
|
||||
## Filter pattern
|
||||
|
||||
Double pointer fields: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
|
||||
|
||||
`SQLFragment()` must return a **static** SQL string (no conditional string building) so the prepared statement is always the same. Use `CASE WHEN` in SQL to handle optional filters.
|
||||
|
||||
`SQLArguments()` returns `pgx.StrictNamedArgs` — **every key referenced in the SQL must be set in every code path** (use `nil` for inactive filters). `StrictNamedArgs` rejects missing keys at runtime.
|
||||
|
||||
```go
|
||||
func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args := pgx.StrictNamedArgs{
|
||||
"show_on_trust_center": nil,
|
||||
"has_snapshot_filter": false,
|
||||
"filter_snapshot_id": nil,
|
||||
}
|
||||
|
||||
if f.showOnTrustCenter != nil {
|
||||
args["show_on_trust_center"] = *f.showOnTrustCenter
|
||||
}
|
||||
|
||||
if f.snapshotID != nil {
|
||||
args["has_snapshot_filter"] = true
|
||||
if *f.snapshotID != nil {
|
||||
args["filter_snapshot_id"] = **f.snapshotID
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (f *VendorFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
CASE
|
||||
WHEN @show_on_trust_center::boolean IS NOT NULL THEN
|
||||
show_on_trust_center = @show_on_trust_center::boolean
|
||||
ELSE TRUE
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @has_snapshot_filter::boolean = false THEN TRUE
|
||||
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
|
||||
snapshot_id = @filter_snapshot_id::text
|
||||
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
|
||||
snapshot_id IS NULL
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
```
|
||||
|
||||
## OrderField pattern
|
||||
|
||||
OrderField types must validate their value via `IsValid()` and implement text marshalling:
|
||||
|
||||
```go
|
||||
type InvitationOrderField string
|
||||
|
||||
const (
|
||||
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (p InvitationOrderField) Column() string {
|
||||
switch p {
|
||||
case InvitationOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
panic(fmt.Sprintf("unsupported order by: %s", p))
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case InvitationOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) String() string { return string(e) }
|
||||
|
||||
func (e *InvitationOrderField) UnmarshalText(text []byte) error {
|
||||
*e = InvitationOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid InvitationOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
```
|
||||
|
||||
## Argument merging
|
||||
|
||||
Always use `maps.Copy` to combine args from scope, filter, and cursor. Never manually merge.
|
||||
|
||||
## Migrations
|
||||
|
||||
Pure SQL files in `pkg/coredata/migrations/` with timestamp names: `YYYYMMDDTHHMMSSZ.sql`.
|
||||
@@ -1,55 +0,0 @@
|
||||
# pkg/server/api/console/v1
|
||||
|
||||
GraphQL API using `gqlgen`. Schema-first approach.
|
||||
|
||||
## Generated vs hand-written
|
||||
|
||||
| File | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `graphql/*.graphql` | Hand-written | GraphQL schema split by entity (one file per coredata model) |
|
||||
| `gqlgen.yaml` | Hand-written | Codegen config |
|
||||
| `resolver.go` | Hand-written | Root `Resolver` struct and `NewMux` |
|
||||
| `graphql_handler.go` | Hand-written | Handler setup |
|
||||
| `*_resolvers.go` | Generated stubs | Per-entity resolver files (edit the bodies) |
|
||||
| `schema/schema.go` | **Generated — DO NOT EDIT** | Executable schema |
|
||||
| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions |
|
||||
|
||||
## Schema file organization
|
||||
|
||||
Schema files live in `graphql/` and are split by coredata model:
|
||||
- `base.graphql` — directives, scalars, Node, PageInfo, root Query/Mutation/Organization/Viewer types
|
||||
- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Organization`, `extend type Mutation`, etc. to add fields
|
||||
|
||||
When adding a new entity, create a new `.graphql` file in `graphql/`. Types that get extended across files (Organization, Mutation, Viewer) must be defined in `base.graphql`.
|
||||
|
||||
## Important rules
|
||||
|
||||
- **Never edit generated files** (`schema/schema.go`, `types/types.go`). Only edit `graphql/*.graphql` and resolver bodies.
|
||||
- **After any change to `graphql/*.graphql`**, always run codegen:
|
||||
|
||||
```
|
||||
go generate ./pkg/server/api/console/v1
|
||||
```
|
||||
|
||||
## Resolver pattern
|
||||
|
||||
Every resolver method follows this sequence:
|
||||
|
||||
1. **Authorize** — `r.authorize(ctx, obj.ID, probo.ActionXxxGet)`
|
||||
2. **Get service** — `prb := r.ProboService(ctx, tenantID)`
|
||||
3. **Call service** — `result, err := prb.Foo.Bar(ctx, ...)`
|
||||
4. **Handle error** — wrap or panic on unexpected errors
|
||||
|
||||
## Pagination
|
||||
|
||||
Relay cursor pattern:
|
||||
- `page.Cursor[OrderField]` for cursor handling
|
||||
- Connection types (`*Connection`) with `ParentID`, `Resolver`, `Filter` fields
|
||||
|
||||
## Custom scalars
|
||||
|
||||
`ID`, `Datetime`, `CursorKey`, `Duration`, `BigInt`, `EmailAddr` — mapped in `gqlgen.yaml`.
|
||||
|
||||
## Authentication middleware
|
||||
|
||||
`NewMux()` chains: session → API key → identity presence middlewares.
|
||||
@@ -1,80 +0,0 @@
|
||||
# pkg/server/api/mcp/v1
|
||||
|
||||
MCP (Model Context Protocol) API. Schema-first approach using `mcpgen`.
|
||||
|
||||
## Generated vs hand-written
|
||||
|
||||
| File | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `specification.yaml` | Hand-written | Tool definitions, input/output schemas |
|
||||
| `mcpgen.yaml` | Hand-written | Codegen config |
|
||||
| `resolver.go` | Hand-written | Resolver struct, `MustAuthorize()`, helpers |
|
||||
| `v1_handler.go` | Hand-written | `NewMux()`, MCP server setup |
|
||||
| `middleware.go` | Hand-written | API key authentication |
|
||||
| `helpers.go` | Hand-written | Pagination helpers |
|
||||
| `schema.resolvers.go` | **Generated (preserved)** | Tool implementations — edit the bodies |
|
||||
| `server/server.go` | **Generated — DO NOT EDIT** | Tool registration, `ResolverInterface` |
|
||||
| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions and JSON schemas |
|
||||
| `types/*.go` (other) | Hand-written | Type conversion helpers (`NewVendor`, etc.) |
|
||||
|
||||
## Important rules
|
||||
|
||||
- **Never edit generated files** (`server/server.go`, `types/types.go`). Only edit `specification.yaml`, resolver bodies, and hand-written helpers.
|
||||
- **After any change to `specification.yaml`**, always run codegen:
|
||||
|
||||
```
|
||||
go generate ./pkg/server/api/mcp/v1
|
||||
```
|
||||
|
||||
Reads `specification.yaml` and generates server, types, and resolver stubs.
|
||||
|
||||
## Adding a new tool
|
||||
|
||||
1. Define the tool in `specification.yaml` under `tools:` with name, description, hints, inputSchema, outputSchema
|
||||
2. Define input/output schemas under `components/schemas/`
|
||||
3. Run `go generate ./pkg/server/api/mcp/v1`
|
||||
4. Implement the tool body in `schema.resolvers.go`
|
||||
5. Add type conversion helpers in `types/` if needed
|
||||
|
||||
## Tool definition format
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- name: listVendors
|
||||
description: List all vendors for the organization
|
||||
hints:
|
||||
readonly: true
|
||||
idempotent: true
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsOutput"
|
||||
```
|
||||
|
||||
## Resolver pattern
|
||||
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(ctx context.Context, input types.ListVendorsInput) (*types.ListVendorsOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
// ... service call, type conversion
|
||||
}
|
||||
```
|
||||
|
||||
- `MustAuthorize()` panics on auth failure — caught by MCP recovery middleware
|
||||
- Type conversion via `types.New*()` helpers
|
||||
|
||||
## Custom type mappings
|
||||
|
||||
In `specification.yaml`, map Go types with `go.probo.inc/mcpgen/type`:
|
||||
|
||||
```yaml
|
||||
OrderDirection:
|
||||
type: string
|
||||
enum: [ASC, DESC]
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/page.OrderDirection
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
API key auth via `authn.NewAPIKeyMiddleware`. Mounted at `/mcp/v1`.
|
||||
Reference in New Issue
Block a user