Loops? Graphs? Prolog!

· Substack von Andreas ·

9 min read Original article ↗

So, apparently people are discussing how to orchestrate agents not just with loops now, but also graphs:

This framing is somewhat confusing to me. Or at least the timing of it. Didn’t we have LangGraph already since like…forever? What have all these other agent frameworks been doing? Or is this about letting your agent write the prompts for other agents? Who knows…

What I do know is that whatever loop or graph-based orchestration you desire, it should be easily implementable using an actual Turing-complete language. So why not try Prolog!

Prolog itself (or rather modern implementations such as SWI or Scryer Prolog) might not be exactly the right choice to do this of course. This is where the DeepClause project comes in. It introduces a language called DML: a Prolog dialect purpose-built for programming AI agents. You get LLM calls as native predicates, automatic backtracking across model invocations and agent memory, typed outputs, and tool orchestration — all in a few lines of declarative code that get executed by TypeScript and WASM based runtime (it actually uses SWI Prolog through WASM).

So let’s try it out and build something real: a vulnerability scanner agent that analyzes a codebase, finds security issues, and reports them. We’ll start dead simple and layer on capabilities.

First, install and configure DeepClause:

npm install -g deepclause-sdk
deepclause init #this creates .deepclause/ 
deepclause --set-model [your favorite model, e.g. openai:gpt-5.4-mini]

Having completed our setup, let’s start by defining our agent. Open a file in the current directory (where your .deepclause folder is), name it “vuln-scanner.dml” and add the following code:

agent_main :-
    system(”You are a senior security researcher.
            You specialize in finding vulnerabilities
            in source code.“),
    task(”Analyze common web application patterns and
          list the top 5 vulnerability categories
          you would look for during a security audit.“),
    answer(“Analysis complete.”).

What does this mean?

  • Every DML program starts with agent_main. This is the agent entry point.

  • system/1 sets the LLM’s persona. It goes into the conversation as a system message — persistent context that shapes every subsequent call.

  • task/1 sends a request to the model. The LLM thinks, responds, and execution continues. This will trigger a full blown agent loop. If we had defined any tools for our agent, they might get used here as well (see next example for more details).

  • answer/1 emits the final result and commits — no backtracking happens after this. The program is done.

Run it:

deepclause run vuln-scanner.dml

Depending on what model you chose, this will produce a nice list of OWASP categories or similar (use —stream and —verbose options to better understand what’s going on). Useful as a starting point, but not exactly a scanner. It’s just talking to itself. Let’s make it interactive.

Now, let’s add a tool that the LLM can use to talk to users. In DML we defined LLM-tools with the tool/2 predicate — a name, a description (shown to the LLM), and a body that does the actual work:

% Tool: Ask the user a question
tool(ask(Prompt, Response),
     “Ask the user a question and get their response”) :-
    exec(ask_user(prompt: Prompt), Result),
    Response = Result.user_response.

agent_main :-
    system(”You are a senior security researcher.
            You specialize in finding vulnerabilities
            in source code.“),
    task(”Ask the user what codebase or file they want
          to analyze for security vulnerabilities.
          Store their response in Target.“, Target),
    task(”Based on the target ‘{Target}’, outline a
          security audit plan. List what you would check
          and why. Store the plan in Plan.”, Plan),
    output(Plan),
    answer(“Audit plan generated.”).

tool/2 makes a predicate visible to the LLM as a tool. When we tell the model to “ask the user” in a task(), it can call our ask tool — DML routes that to the exec/2 call under the hood, which invokes the registered ask_user external tool (things callable through the exec predicate are actually defined in DeepClause’s typescript layer, which maybe extended through the DeepClause SDK). The response flows back into the Prolog variable “Response”. The key insight: tools defined with tool/2 are available to the LLM during any task() call. The model decides when to use them.

task/2 (with two arguments) is where DML gets interesting. The second argument is an output variable. We instruct the model to “ask the user” and “store their response in Target” — the LLM calls the ask tool, gets the user’s answer, and the runtime binds it to Target. You reference the variable name in the prompt itself, and the runtime extracts it. This is how data flows between steps: not through callbacks or shared mutable state, but through Prolog unification.

output/1 emits progress to the caller without ending execution. Unlike answer/1, it doesn’t commit — the program keeps running.

The model now asks the user what to scan, gets a target, and produces a plan. Better. But a plan without execution is just a document.

Let’s give our agent hands. The vm_exec built-in runs shell commands in a (kind of) sandboxed environment (by default this is via bwrap on linux, sandbox-exec on macos, plus some fallbacks in case that’s not available):

% Tool: Ask the user a question
tool(ask(Prompt, Response),
     “Ask the user a question and get their response”) :-
    exec(ask_user(prompt: Prompt), Result),
    Response = Result.user_response.

% Tool: Run a shell command in the sandbox
tool(run_shell(Command, Output),
     “Run a shell command and return stdout”) :-
    exec(vm_exec(command: Command), Result),
    get_dict(stdout, Result, Output).

% Tool: Read a file
tool(read_file(Path, Content),
     “Read the contents of a file”) :-
    format(string(Cmd), “cat ‘~w’”, [Path]),
    exec(vm_exec(command: Cmd), Result),
    get_dict(stdout, Result, Content).

agent_main :-
    system(”You are a senior security researcher performing
            a hands-on code audit. You can run shell commands
            and read files. Be thorough and methodical.”),

    task(”Ask the user for the directory to scan.
          Store their answer in Target.“, Target),

    output(“Scanning codebase...”),
    task(”Explore the directory ‘{Target}’:
          1. List all source files using run_shell
          2. Identify the languages and frameworks used
          3. Read key files that are likely to contain
             security-relevant code (auth, input handling,
             database queries, API endpoints)
          Store a summary of what you found in Recon.“, Recon),

    output(“Analyzing for vulnerabilities...”),
    task(”Based on your reconnaissance: {Recon}

          Read the relevant source files and analyze them
          for security vulnerabilities. Look for:
          - SQL injection
          - XSS (cross-site scripting)
          - Authentication/authorization flaws
          - Hardcoded secrets or credentials
          - Insecure deserialization
          - Path traversal
          - Command injection

          For each finding, note the file, line, and a
          brief explanation.

          Store your findings in Findings.“, Findings),

    output(“Generating report...”),
    task(”Write a concise security report based on these
          findings: {Findings}

          Format as Markdown with severity ratings
          (Critical/High/Medium/Low) for each issue.
          Include remediation advice.
          Store the report in Report.“, Report),

    output(Report),
    answer(“Security audit complete.”).

Now the agent actually does things. It runs ls, find, cat — reads source files, greps for patterns, and applies security expertise to real code. The LLM chooses which tools to use and when, but the control flow — the sequence of recon → analysis → reporting — is deterministic. Defined in Prolog. No prompt engineering gymnastics to keep the model on track.

Notice how tool definitions are just Prolog clauses. read_file is syntactic sugar built on run_shell via format/3. You can compose tools arbitrarily, add validation logic, error handling — it’s all just Prolog.

Here’s where DML earns its keep. So far, we’ve been treating LLM outputs as opaque strings. But what if we need structured decisions?

DML supports type-safe output variables. Wrap a variable in integer(), boolean(), list(string()), or object() and the runtime enforces the type with schema validation — no more parsing JSON from markdown code fences.

Let’s use this to build a proper triage system:

% --- Tool Definitions (same as before) ---

tool(ask(Prompt, Response),
     “Ask the user a question and get their response”) :-
    exec(ask_user(prompt: Prompt), Result),
    Response = Result.user_response.

tool(run_shell(Command, Output),
     “Run a shell command and return stdout”) :-
    exec(vm_exec(command: Command), Result),
    get_dict(stdout, Result, Output).

tool(read_file(Path, Content),
     “Read the contents of a file”) :-
    format(string(Cmd), “cat ‘~w’”, [Path]),
    exec(vm_exec(command: Cmd), Result),
    get_dict(stdout, Result, Content).

% --- Severity-based response ---

handle_findings(Count, _) :-
    Count =:= 0,
    answer(“No vulnerabilities found. The codebase looks clean.”).

handle_findings(Count, Findings) :-
    Count > 0, Count =< 3,
    task(”Write a brief security advisory for these
          low-count findings: {Findings}
          Store the advisory in Advisory.“, Advisory),
    answer(Advisory).

handle_findings(Count, Findings) :-
    Count > 3,
    output(“⚠  High number of findings — running deep analysis...”),
    task(”These findings need detailed remediation plans: {Findings}
          For each vulnerability:
          1. Explain the attack vector
          2. Show a proof-of-concept (safe, illustrative)
          3. Provide the exact code fix

          Store the deep analysis in DeepReport.“, DeepReport),
    task(”Write an executive summary of this security audit.
          Total issues: {Count}. Store in Summary.”, Summary),
    output(Summary),
    answer(DeepReport).

% --- Main ---

agent_main :-
    system(”You are a senior security researcher.
            Be precise about vulnerability counts.“),

    task(”Ask the user which directory to audit
          using the ask tool. Store their answer in Target.”,
         Target),

    output(“Scanning...”),
    task(”Scan ‘{Target}’ for source files and read the
          security-relevant ones. Analyze for vulnerabilities.
          Return the total number of distinct vulnerabilities
          found in Count.“,
         integer(Count)),

    task(”List each vulnerability with file, line number,
          and category. Store as Findings.”,
         list(string(Findings))),

    format(string(Msg),
           “Found ~w potential vulnerabilities.”, [Count]),
    output(Msg),

    handle_findings(Count, Findings).

Let’s trace what happens:

  1. integer(Count) — the LLM must return a number. Not “about five” or “several.” An integer. The runtime automatically validates this with a dynamically generated Zod schema and will retry if the model returns garbage.

  2. list(string(Findings)) — the findings come back as a proper list of strings. This gives you a Prolog list you can length/2, member/2, or iterate over.

  3. handle_findings/2 uses Prolog’s multiple clause definitions (so this would correspond to a few if or switch statements in imperative languages).

    • Zero findings → clean bill of health

    • 1–3 findings → brief advisory

    • 4+ findings → deep analysis with PoCs and fixes

In about 60 lines of DML, we built a vulnerability scanner that:

  • Talks to the user

  • Explores a real filesystem

  • Reads and analyzes source code

  • Returns typed, validated results

  • Branches behavior based on structured LLM output

  • Has automatic retry semantics via backtracking

What is important here is that the above steps will be followed no matter what. Of course, you could specify all of the above DML code in a markdown file. But would you have a 100% guarantee that your model would always follow your markdown instructions?

Diskussion über diese Post

Sind Sie bereit für mehr?