From an ankka application¶
ankka-satisfactory is the library an ankka application adds to use
satisfactory. It provides three things and mounts nothing: agent tools generated from a model's catalog,
a verifier for the webhook satisfactory sends, and a fake that answers the client and fires webhooks in
a test. The endpoint that receives the webhook is yours to write, with its own path and its own access
control list, because a library that mounted a route with an ACL nobody declared would break ankka's
rule that every endpoint states who may call it.
A solve takes minutes and an ankka tool call is synchronous, so the shape is: the agent decides and submits, a workflow owns the wait, and the webhook resumes the workflow. The workflow's pause is journaled, so a crash mid-solve resumes at the wait rather than submitting twice.
Add the dependency¶
libraryDependencies += "com.thinkmorestupidless" %% "ankka-satisfactory" % "<version>"
It depends on satisfactory-client and on ankka's ankka-sdk, ankka-http and ankka-agent
libraries, and declares the ankka version it was built against.
Tools from the catalog¶
SatisfactoryTools.forModel(client, key, version, tags) fetches the model's descriptor once and returns
three FunctionTool values for an agent:
| Tool | Parameters | Does |
|---|---|---|
solve_<model> |
dataset, name, spentLimit |
submits the dataset and returns a SolveToolResult with the datasetId |
get_best_solution_<model> |
datasetId |
returns the dataset's best solution so far, as JSON |
terminate_<model> |
datasetId |
stops solving and returns the dataset's metadata |
<model> is the registration key with hyphens replaced by underscores, so employee scheduling's tools
are solve_employee_scheduling, get_best_solution_employee_scheduling and
terminate_employee_scheduling. The dataset parameter's schema is the model's own input schema, with
its references inlined, and the arguments are checked against that schema before anything is sent: an
argument that does not match is refused in the tool's reply, naming up to ten problems, and no request
leaves the process. The agent therefore sees the same validation a submit would apply.
The tags are attached to every submit the tools make. SatisfactoryTools.workflowTag(id) and
sessionTag(id) produce ankka-workflow:<id> and ankka-session:<id>, and satisfactory carries a
dataset's tags in every webhook event, so an event finds its way back to the workflow that caused it with
no mapping to maintain. This agent gives the tools to the model with the workflow's tag:
/** An agent that decides what to solve, and submits it with the generated tool. */
final class SchedulerAgent extends Agent:
def plan(request: PlanRequest): Effect[String] =
effects
.systemMessage("You schedule staff. Submit the roster with the solve tool.")
.userMessage(request.brief)
.tools(SatisfactoryTools.forModel(Satisfactory.client, "employee-scheduling", "v1", List(SatisfactoryTools.workflowTag(request.workflowId)))*)
.memory(MemoryProvider.none)
.thenReply()
object SchedulerAgent extends Agent.Companion[SchedulerAgent](ComponentId("scheduler-agent")):
given Serializer[PlanRequest] = Codecs.serializer[PlanRequest]("plan-request")
def create(context: AgentContext) = new SchedulerAgent
val plan = command("plan")(_.plan)
The tool's description tells the model that the solution arrives later and not to wait for it. The
solve tool answers a submit refusal, such as a dataset the model rejects, with a SolveToolResult
whose error and details carry the API's message, so the agent can correct the input.
A workflow owns the wait¶
The workflow consults the agent in one step, then pauses. thenPause(after, onTimeout) is ankka's own
workflow effect: the workflow sleeps until a command transitions it, or until the timeout step runs. The
command the webhook receiver calls is solutionReady; it transitions to a step that fetches the solution
with the client. A second delivery of the same event finds the workflow past waiting and changes
nothing, which is how at-least-once delivery is handled:
/** The wait belongs to a workflow, not the agent (DESIGN.md ยง11.2b): it pauses until the webhook. */
final class RosterWorkflow(context: WorkflowContext) extends Workflow[Roster]:
def emptyState: Roster = Roster("new", None, None, 0)
def start(brief: String): Effect[Done] =
effects.updateState(Roster("consulting", None, None, 0)).transitionTo(RosterWorkflow.consult.withInput(brief)).thenReply(Done)
def consultStep(brief: String): StepEffect =
val _ = context.componentClient.forAgent(SessionId(context.workflowId)).call(SchedulerAgent.plan).invoke(PlanRequest(context.workflowId, brief))
stepEffects.updateState(currentState.copy(status = "waiting")).thenPause(5.minutes, RosterWorkflow.timedOut.ref)
/** The webhook's receiver calls this. A duplicate delivery finds the work done and changes nothing. */
def solutionReady(datasetId: String): Effect[Done] =
if currentState.status != "waiting" then effects.updateState(currentState.copy(resumed = currentState.resumed + 1)).thenReply(Done)
else effects.updateState(currentState.copy(status = "fetching", datasetId = Some(datasetId), resumed = currentState.resumed + 1)).transitionTo(RosterWorkflow.fetch.ref).thenReply(Done)
def fetchStep: StepEffect =
val result = Satisfactory.client.model("employee-scheduling", "v1").get(currentState.datasetId.get)
stepEffects.updateState(currentState.copy(status = "done", score = result.metadata.score)).thenEnd
def timedOutStep: StepEffect = stepEffects.updateState(currentState.copy(status = "timed-out")).thenEnd
def get: ReadOnlyEffect[Roster] = effects.reply(currentState)
object RosterWorkflow extends Workflow.Companion[RosterWorkflow, Roster](ComponentId("roster"), Codecs.serializer[Roster]("roster")):
def create(ctx: WorkflowContext) = new RosterWorkflow(ctx)
val consult = step[String]("consult")(_.consultStep)
val fetch = step("fetch")(_.fetchStep)
val timedOut = step("timed-out")(_.timedOutStep)
val start = command("start")(_.start)
val solutionReady = command("solution-ready")(_.solutionReady)
val get = query("get")(_.get)
The timeout step is what handles a webhook that never arrives. Five minutes is this sample's choice; a production workflow would transition to a step that reads the dataset's metadata once and decides.
The endpoint you write¶
The webhook is a POST with a JSON body and four headers: X-Satisfactory-Signature,
X-Satisfactory-Timestamp, X-Satisfactory-Event and X-Satisfactory-Delivery. The signature is an
HMAC-SHA256 over <timestamp>.<body>, Base64 encoded, keyed with the subscription's secret.
Receive webhooks has the wire detail.
SatisfactoryWebhook gives you two checks. looksSigned() is a predicate for Acl.AllowIf: it refuses
a request with no signature header or a timestamp older than five minutes before any code of yours runs,
and it does not check the HMAC, because an ACL cannot see the body. verify(secret, request, body) checks
the HMAC over the body's exact bytes, refuses a stale timestamp or a mismatch, and decodes the envelope
into an Event.Dataset or an Event.WebhookFailing. workflowOf(event) reads the workflow id back out
of the tags:
/** The developer's own endpoint: its path, its ACL. The library only checks and decodes. */
final class HooksEndpoint(client: ComponentClient) extends HttpEndpoint("/hooks"):
val acl: Acl = Acl.AllowIf(SatisfactoryWebhook.looksSigned())
postBody("/satisfactory") { (body: Array[Byte]) =>
val answered: Unit =
SatisfactoryWebhook.verify(Satisfactory.secret, request, body) match
case Right(SatisfactoryWebhook.Event.Dataset(event)) =>
SatisfactoryWebhook.workflowOf(event).foreach(id =>
client.forWorkflow(EntityId(id)).call(RosterWorkflow.solutionReady).invoke(event.data.id): Unit
)
case Right(_) => ()
case Left(refusal) => throw HttpProblem.unauthorized(refusal.toString)
answered
}
Two things in this endpoint are deliberate. postBody takes the body as Array[Byte], with the
FromBody[Array[Byte]] instance imported from SatisfactoryWebhook.given, so the verifier signs the
bytes satisfactory sent and not a re-encoding of them. And the endpoint answers before it does any work:
it hands the dataset id to the workflow and returns, which is what a webhook sender expects. The sender
waits five seconds for an answer and retries up to ten times, ten seconds apart.
A refusal from verify becomes a 401 through HttpProblem.unauthorized; a request the ACL refuses is a
403. Register the endpoint with HttpServer.of as you would any other, expose the service, and register
https://<service>-<project>.<domain>/hooks/satisfactory as a webhook subscription on the tenant. That
registration is an operator's act through the Platform API, not something
the application does at startup.
Test it with the fake¶
FakeSatisfactory is an in-memory HTTP server that answers the client. It always answers the model
catalog, and everything else is scripted in order: expectSubmit answers the next submit, expectGet
the next fetch of a dataset, and expect(method, pathEndsWith) anything else. A call the script does not
cover is answered with a 500 whose message names the call, and verifyScript() throws if any script is
left over or any call was unscripted, so a test cannot pass by accident:
test("the catalog is always answered; scripted calls answer in order and are recorded") {
val fake = FakeSatisfactory(descriptor)
try
fake.expectSubmit((_, request) => metadata("ds_fake_1")).expectGet("ds_fake_1", DatasetResponse(metadata("ds_fake_1"), Some(RawJson("{}")), None, None))
val model = fake.client().model("employee-scheduling", "v1")
assertEquals(model.descriptor.entity, "schedules")
assertEquals(model.submit(SubmitRequest(RawJson("""{"employees":[],"shifts":[]}"""))).id, "ds_fake_1")
assertEquals(model.get("ds_fake_1").metadata.id, "ds_fake_1")
assertEquals(fake.remaining, 0)
fake.verifyScript()
finally fake.close()
}
FakeSatisfactory.describe(model) builds a descriptor from a SolverModel, which is how the tests above
describe employee scheduling. An application that has no model on its classpath constructs a
ModelDescriptor directly, with the input schema its agent's tool should validate against.
fire(event, url, secret) posts a signed DatasetEvent to an endpoint and returns the status, so the
whole loop runs under AnkkaTestKit with nothing of satisfactory running:
override def beforeAll(): Unit =
Satisfactory.client = fake.client()
val server = HttpServer.at("127.0.0.1", 0)(clients => HooksEndpoint(clients.componentClient))
testKit = AnkkaTestKit.start(
Seq(SchedulerAgent.descriptor, RosterWorkflow.descriptor) ++ AgentRuntime.descriptors,
Seq(AgentRuntime.withDefaultModel(model), server)
)
hooks = s"http://127.0.0.1:${server.boundPort.get}/hooks/satisfactory"
The fake records every request it receives in received, so a test can assert that the submit carried
the workflow's tag. A tampered signature makes fire return 401, and an unsigned request is refused by
the ACL with 403.
Mistakes to check for¶
- A tool call that waits for the solution. The solve tool returns a dataset id; the wait belongs to a workflow's pause, never to an LLM polling in a loop.
- A webhook endpoint with
Acl.AllowAll. UseAcl.AllowIf(SatisfactoryWebhook.looksSigned()), thenverifyin the handler. - Reading the body as a
Stringor a case class before verifying. The signature covers the bytes on the wire. - A
solutionReadycommand that does work twice. A duplicate delivery must be a no-op. - Registering the webhook from application code. It is a tenant setting made by an operator.