From 354312d328e6d26315f95b41178a48cb8e9081cd Mon Sep 17 00:00:00 2001 From: max funk Date: Sat, 19 Sep 2026 20:14:33 -0700 Subject: [PATCH] metrics: op=pin writes the flag the prompt reads `_queries_block()` reads `pinned` on a `metric_queries` row and nothing wrote it: `write_schema op=extend` saves a row's schema alone. The tool gains `op=pin {name, pinned}`: one UpdateItem on the row, a canonical name copied in first so a pin never needs a prior call, 404 on an unknown name, 400 on a non-boolean. The manage role gains UpdateItem on the schema table. modules/agent's AGENTS names the block in the dynamic tail, which the #30 build left out. Co-Authored-By: Claude Fable 5.1 --- modules/agent/AGENTS.md | 8 ++++++-- modules/metrics/AGENTS.md | 4 +++- modules/metrics/infra/main.tf | 2 +- modules/metrics/kb.md | 5 +++-- modules/metrics/lambdas/manage_metrics/main.py | 11 ++++++++++- modules/metrics/lambdas/manage_metrics/rows.py | 9 +++++++++ modules/metrics/lambdas/manage_metrics/schema.json | 11 ++++++++--- tests/metrics/local/test_query.py | 14 ++++++++++++++ 8 files changed, 54 insertions(+), 10 deletions(-) diff --git a/modules/agent/AGENTS.md b/modules/agent/AGENTS.md index d2d8edd..0f7c7a1 100644 --- a/modules/agent/AGENTS.md +++ b/modules/agent/AGENTS.md @@ -265,8 +265,8 @@ Adding a new domain (`modules/payroll/`, `modules/inventory_v2/`): drop the dire Every turn's system prompt is assembled as: ``` -_system + _date_block() + _instruction_block() + _memory_block() - baked today the firm's list this caller's facts +_system + _date_block() + _instruction_block() + _queries_block() + _memory_block() + baked today the firm's list the reads kept handy this caller's facts ``` `_system` is composed once at container start from `prompts/` and is identical for every turn; the @@ -281,6 +281,10 @@ into `_system`, or ordering one ahead of the baked block, forecloses that for no - `_instruction_block()` — the firm's `INSTRUCTION#` rows, one prefix Query, firm-scoped so it loads for every caller including pokers and scheduled invokes. Empty list ⇒ no section at all: an empty heading is prompt weight and an invitation to invent policy. +- `_queries_block()` — the product reads this firm keeps handy (modules/metrics): the + `metric_queries` rows the owner pinned (`manage_metrics op=pin`, no cap), then the names the + firm ran last (the usage table's newest rows, `GERP#recent_queries` many, default 10), as name, + description and parameters, never the SQL. Two Queries and a GetItem; empty ⇒ no section. - `_memory_block()` — the caller's `MEMORY##` rows, one prefix Query, gated on the JWT-verified caller ContextVar (no caller ⇒ nothing). diff --git a/modules/metrics/AGENTS.md b/modules/metrics/AGENTS.md index 3961a8c..5671184 100644 --- a/modules/metrics/AGENTS.md +++ b/modules/metrics/AGENTS.md @@ -26,7 +26,9 @@ and joined to the books. Why in `README.md`. from the OS, SecureString) and returns it once with the url; the same caller again rotates. `unpublish_source` deletes it. `list_sources` names callers (DescribeParameters, no values). `record` writes one event from the conversation. `query {name, params, window|start+end}` is - the read: a `metric_queries` registry row run by name (below). There is no inline SQL + the read: a `metric_queries` registry row run by name (below). `pin {name, pinned}` sets or + clears the row's `pinned` flag, which the prompt's dynamic tail reads every turn (the agent + module), with no cap on how many. There is no inline SQL - **`record_metric`** (`metric_rules.py`) — params `{event, subject: , properties: {name: }}`. Reads the subject and the properties off the callsite's ctx, sends with `via: rule` and the exec id, returns `[]` so a callsite that folds returns into postings is diff --git a/modules/metrics/infra/main.tf b/modules/metrics/infra/main.tf index 7ee1d18..60b5dfa 100644 --- a/modules/metrics/infra/main.tf +++ b/modules/metrics/infra/main.tf @@ -524,7 +524,7 @@ resource "aws_iam_role_policy" "manage" { # a query is a metric_queries row in the registry table: read by name, and a canonical one # written on first use Effect = "Allow" - Action = ["dynamodb:Query", "dynamodb:PutItem"] + Action = ["dynamodb:Query", "dynamodb:PutItem", "dynamodb:UpdateItem"] Resource = "arn:aws:dynamodb:${local.region}:${local.account_id}:table/${local.schema_table}" }, { diff --git a/modules/metrics/kb.md b/modules/metrics/kb.md index 07b79a1..620ad93 100644 --- a/modules/metrics/kb.md +++ b/modules/metrics/kb.md @@ -101,8 +101,9 @@ save it, then call it: manage_metrics op=query name=joined_by_plan params={"event": "member.joined"} window=last_month `params` is the order of the `?` markers; `start`, `end`, `zone` and `grain` are filled from the -window when the row declares them. There is no way to run SQL that is not a row. Say "keep this one -handy" and the row is pinned into every turn's prompt. +window when the row declares them. There is no way to run SQL that is not a row. When the owner says +to keep one handy, `manage_metrics op=pin name=` puts the row into every turn's prompt; +`pinned=false` takes it out. As many as the owner wants. The joins are yours: revenue per active member is `get_statement` for the period over `active` for the same period; cost per check-in is the cost structure over `count`. diff --git a/modules/metrics/lambdas/manage_metrics/main.py b/modules/metrics/lambdas/manage_metrics/main.py index 7a6c76f..f174752 100644 --- a/modules/metrics/lambdas/manage_metrics/main.py +++ b/modules/metrics/lambdas/manage_metrics/main.py @@ -5,6 +5,7 @@ op: list_sources who may POST op: record one event now, from the conversation op: query a read by name: a `metric_queries` registry row, its parameters bound + op: pin keep a query in front of the agent every turn, or stop A query is a registry row (`rows.py`): the gerp's own, or a canonical one copied in on first use. There is no inline SQL; a query the agent writes is saved as a row (`write_schema op=extend`) and @@ -31,7 +32,7 @@ TOKEN_PREFIX = "METRICS_TOKEN_" CALLER_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") -OPS = ("publish_source", "unpublish_source", "list_sources", "record", "query") +OPS = ("publish_source", "unpublish_source", "list_sources", "record", "query", "pin") def ok(body, code=200): @@ -137,6 +138,14 @@ def _usage(name: str, engine: str, result: dict) -> None: }) +def _pin(body): + """The owner said to keep one handy, or to stop: the row's `pinned` flag, which the prompt's + dynamic tail reads every turn (modules/agent). No cap: the prompt's size is the owner's.""" + if "pinned" in body and not isinstance(body["pinned"], bool): + return err("pinned: true or false") + return ok(rows.pin(body.get("name"), body.get("pinned", True))) + + def _query(body): name = body.get("name") row = rows.read(name) diff --git a/modules/metrics/lambdas/manage_metrics/rows.py b/modules/metrics/lambdas/manage_metrics/rows.py index 3973057..2bbb5da 100644 --- a/modules/metrics/lambdas/manage_metrics/rows.py +++ b/modules/metrics/lambdas/manage_metrics/rows.py @@ -108,6 +108,15 @@ def read(name: str) -> dict: raise NoSuchQuery(name) +def pin(name: str, pinned: bool) -> dict: + """Set or clear `pinned` on the query's row; the prompt's tail carries a pinned row every turn. + A canonical name not yet in the table is copied first, so a pin never needs a prior call.""" + row = read(name) + _table().update_item(Key={"registry": REGISTRY, "bucket_name": f"{row['engine']}#{name}"}, + UpdateExpression="SET pinned = :p", ExpressionAttributeValues={":p": bool(pinned)}) + return {"name": name, "engine": row["engine"], "pinned": bool(pinned)} + + def _copy(engine: str, name: str, entry: dict, pinned) -> None: """The canonical entry as a row of the gerp's table, the shape `seed_schema` writes.""" item = {"registry": REGISTRY, "bucket_name": f"{engine}#{name}", "bucket": engine, "name": name, diff --git a/modules/metrics/lambdas/manage_metrics/schema.json b/modules/metrics/lambdas/manage_metrics/schema.json index 2a1ea98..1169967 100644 --- a/modules/metrics/lambdas/manage_metrics/schema.json +++ b/modules/metrics/lambdas/manage_metrics/schema.json @@ -12,9 +12,10 @@ "unpublish_source", "list_sources", "record", - "query" + "query", + "pin" ], - "description": "publish_source | unpublish_source | list_sources: the apps that may POST. record: one event now. query: a read by name." + "description": "publish_source | unpublish_source | list_sources: the apps that may POST. record: one event now. query: a read by name. pin: keep a query in front of you every turn (pinned true), or stop (false)." }, "caller": { "type": "string", @@ -38,7 +39,7 @@ }, "name": { "type": "string", - "description": "query: the query's name \u2014 a metric_queries row of this firm's, or a canonical one (active, count, count_by, funnel_3, retention), copied in on first use. No inline SQL: save one with write_schema op=extend, then call it." + "description": "query, pin: the query's name \u2014 a metric_queries row of this firm's, or a canonical one (active, count, count_by, funnel_3, retention), copied in on first use. No inline SQL: save one with write_schema op=extend, then call it." }, "params": { "type": "object", @@ -55,6 +56,10 @@ "end": { "type": "string", "description": "query: window end, exclusive, ISO date or datetime in the firm's zone." + }, + "pinned": { + "type": "boolean", + "description": "pin: true to keep the query in every turn's prompt, false to stop. Default true." } } } diff --git a/tests/metrics/local/test_query.py b/tests/metrics/local/test_query.py index dd6503c..aa57987 100644 --- a/tests/metrics/local/test_query.py +++ b/tests/metrics/local/test_query.py @@ -130,6 +130,20 @@ def test_a_canonical_row_follows_the_file_and_a_saved_row_is_left_alone(): assert body["rows"] == [{"mine": 2}], "an extension row is never touched" +def test_a_pin_sets_the_flag_the_prompt_reads_and_a_canonical_name_pins_on_first_use(): + with scratch_env(): + tool = load_lambda("manage_metrics") + r = tool.handler({"body": json.dumps({"op": "pin", "name": "retention"})}, None) + assert r["statusCode"] == 200 and json.loads(r["body"]) == {"name": "retention", "engine": "athena", "pinned": True} + assert query_rows()["retention"]["pinned"]["BOOL"] is True, "copied in and pinned in one call" + r = tool.handler({"body": json.dumps({"op": "pin", "name": "retention", "pinned": False})}, None) + assert json.loads(r["body"])["pinned"] is False and query_rows()["retention"]["pinned"]["BOOL"] is False + r = tool.handler({"body": json.dumps({"op": "pin", "name": "nope"})}, None) + assert r["statusCode"] == 404 + r = tool.handler({"body": json.dumps({"op": "pin", "name": "retention", "pinned": "yes"})}, None) + assert r["statusCode"] == 400 and "pinned: true or false" in json.loads(r["body"])["error"] + + def test_a_missing_name_fails_naming_it_and_there_is_no_inline_sql(): with scratch_env(): tool = load_lambda("manage_metrics")